#!/usr/bin/env bash # perfsmoke.sh — assert PLAN.md §11 *Performance budgets* against the # built AppImage. Run via `make perf` or as a CI step before publishing # a release. The numbers below are the budgets; override via env vars # when measuring on slow shared runners (with care — they exist for a # reason). set -euo pipefail APPIMAGE="${1:-$(dirname "${BASH_SOURCE[0]}")/../build/Todo-x86_64.AppImage}" APPIMAGE="$(readlink -f "$APPIMAGE")" [ -x "$APPIMAGE" ] || { echo "AppImage not found: $APPIMAGE" >&2; exit 1; } : "${PERF_COLD_START_MS:=2000}" : "${PERF_IDLE_MEM_MB:=200}" : "${PERF_BUNDLE_MB:=200}" : "${PERF_BACKEND_PORT:=8765}" : "${PERF_HEALTHZ_DEADLINE_MS:=5000}" DATA_DIR="$(mktemp -d)" trap 'cleanup' EXIT INT TERM PID="" cleanup() { trap - EXIT INT TERM if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then kill -TERM "$PID" 2>/dev/null || true for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "$PID" 2>/dev/null || break sleep 0.2 done kill -KILL "$PID" 2>/dev/null || true fi rm -rf "$DATA_DIR" } step() { echo "→ $*"; } fail() { echo "✗ $*" >&2; exit 1; } # ── Bundle size ────────────────────────────────────────────────────── SIZE_BYTES=$(stat -c %s "$APPIMAGE") SIZE_MB=$(( SIZE_BYTES / 1024 / 1024 )) step "bundle size: ${SIZE_MB} MB (cap ${PERF_BUNDLE_MB} MB)" [ "$SIZE_MB" -le "$PERF_BUNDLE_MB" ] || fail "bundle ${SIZE_MB} MB exceeds ${PERF_BUNDLE_MB} MB" # ── Boot under Xvfb if no display is available ─────────────────────── RUNNER=() if [ -z "${DISPLAY:-}" ]; then if command -v xvfb-run >/dev/null; then RUNNER=(xvfb-run -a) else # Fall back to Qt's offscreen platform (the AppImage build # bundles libqoffscreen.so via EXTRA_PLATFORM_PLUGINS). export QT_QPA_PLATFORM=offscreen echo "no DISPLAY / xvfb-run — falling back to QT_QPA_PLATFORM=offscreen" >&2 fi fi # Use an isolated user data dir so the smoke doesn't trample dev state. export XDG_DATA_HOME="$DATA_DIR/share" export XDG_CACHE_HOME="$DATA_DIR/cache" export APPIMAGE_EXTRACT_AND_RUN=1 mkdir -p "$XDG_DATA_HOME" "$XDG_CACHE_HOME" # Force the port so the curl probe below can hit a known address — # the supervisor would otherwise negotiate a free ephemeral port and # we'd have to read it back from the sentinel file. export BRIDGE_PORT="$PERF_BACKEND_PORT" step "launching AppImage (${RUNNER[*]:-direct})" START_NS=$(date +%s%N) "${RUNNER[@]}" "$APPIMAGE" > "$DATA_DIR/run.log" 2>&1 & PID=$! # ── Cold start: time to first /healthz 200 ─────────────────────────── DEADLINE_NS=$(( START_NS + PERF_HEALTHZ_DEADLINE_MS * 1000000 )) ELAPSED_MS="" while true; do NOW_NS=$(date +%s%N) if [ "$NOW_NS" -ge "$DEADLINE_NS" ]; then sed 's/^/ /' "$DATA_DIR/run.log" >&2 || true fail "cold start exceeded ${PERF_HEALTHZ_DEADLINE_MS} ms deadline" fi if ! kill -0 "$PID" 2>/dev/null; then sed 's/^/ /' "$DATA_DIR/run.log" >&2 || true fail "host died during boot" fi if curl -fsS -m 1 "http://127.0.0.1:${PERF_BACKEND_PORT}/healthz" >/dev/null 2>&1; then END_NS=$(date +%s%N) ELAPSED_MS=$(( (END_NS - START_NS) / 1000000 )) break fi sleep 0.05 done step "cold start: ${ELAPSED_MS} ms (budget ${PERF_COLD_START_MS} ms)" [ "$ELAPSED_MS" -le "$PERF_COLD_START_MS" ] \ || fail "cold start ${ELAPSED_MS} ms exceeds ${PERF_COLD_START_MS} ms" # ── Idle memory: measure host + descendants after a 2 s settle ─────── sleep 2 TOTAL_KB=0 PGID=$(ps -o pgid= -p "$PID" | tr -d ' ') while IFS= read -r p; do [ -r "/proc/$p/status" ] || continue R=$(awk '/^VmRSS:/ {print $2}' "/proc/$p/status" 2>/dev/null || echo 0) TOTAL_KB=$(( TOTAL_KB + R )) done < <(pgrep -g "$PGID" 2>/dev/null) TOTAL_MB=$(( TOTAL_KB / 1024 )) step "idle memory (host + children): ${TOTAL_MB} MB (budget ${PERF_IDLE_MEM_MB} MB)" [ "$TOTAL_MB" -le "$PERF_IDLE_MEM_MB" ] \ || fail "idle memory ${TOTAL_MB} MB exceeds ${PERF_IDLE_MEM_MB} MB" echo echo "✓ perf smoke OK — bundle=${SIZE_MB}MB cold=${ELAPSED_MS}ms idle=${TOTAL_MB}MB"