#!/usr/bin/env bash
# test-route-leak.sh — detect the pipe/recursive-static route churn regression.
#
# Builds the given bird commit in an isolated git worktree, runs it with the
# route-leak repro config, and samples the daemon's CPU time plus the pipe's
# "Import updates" counter twice. A good build is idle after startup; a bad
# build spins a full core re-propagating the same routes. (CPU is the primary
# signal: mid-branch v3 commits had broken CLI stats counters that read 0.)
#
# usage:   ./test-route-leak.sh [commit]        (default: HEAD)
#          git bisect run ./test-route-leak.sh
#
# Testing HEAD (explicitly or by default) includes uncommitted changes to
# tracked files; such builds are cached separately per working-tree diff.
#
# exit:    0   GOOD (counter stable)
#          1   BAD  (counter climbing)
#          125 untestable (build failure etc.) -> git bisect skip
#
# Env overrides:
#   BIRD_TEST_CACHE  build/binary cache dir (default ~/.cache/bird-routeleak)
#   SETTLE_SECS      seconds to wait before first sample   (default 5)
#   SAMPLE_GAP_SECS  seconds between the two samples       (default 7)
#   BAD_DELTA        counter delta considered "climbing"   (default 20)

set -u

REPO=$(cd "$(dirname "$0")" && git rev-parse --show-toplevel) || exit 125
CACHE=${BIRD_TEST_CACHE:-$HOME/.cache/bird-routeleak}
SETTLE=${SETTLE_SECS:-5}
GAP=${SAMPLE_GAP_SECS:-7}
BAD_DELTA=${BAD_DELTA:-20}

commit=$(git -C "$REPO" rev-parse --verify "${1:-HEAD}^{commit}") || exit 125
short=$(git -C "$REPO" rev-parse --short "$commit")

# When testing HEAD itself, include uncommitted changes to tracked files —
# otherwise a fix sitting in the working tree would silently not be tested.
dirty=
if [ "$commit" = "$(git -C "$REPO" rev-parse HEAD)" ] && ! git -C "$REPO" diff --quiet HEAD; then
    dirty=$(git -C "$REPO" diff HEAD | sha256sum | cut -c1-12)
    short="$short+dirty"
fi

bindir="$CACHE/bin-$commit${dirty:+-dirty-$dirty}"

say() { echo "[$short] $*" >&2; }

# ---------------------------------------------------------------- build ----
if [ ! -x "$bindir/bird" ] || [ ! -x "$bindir/birdcl" ]; then
    src="$CACHE/src-$commit"
    blog="$CACHE/build-$commit.log"
    mkdir -p "$CACHE"
    say "building (log: $blog)"

    git -C "$REPO" worktree remove --force "$src" 2>/dev/null
    rm -rf "$src"
    git -C "$REPO" worktree prune
    git -C "$REPO" worktree add --detach "$src" "$commit" >"$blog" 2>&1 || {
        say "worktree add failed"; exit 125; }

    if [ -n "$dirty" ] && ! git -C "$REPO" diff HEAD | git -C "$src" apply >>"$blog" 2>&1; then
        say "failed to apply uncommitted changes onto worktree"
        git -C "$REPO" worktree remove --force "$src" 2>/dev/null
        exit 125
    fi

    build_ok=0
    (
        cd "$src" &&
        autoreconf -i &&
        mkdir -p obj && cd obj &&
        ../configure --disable-client --disable-libssh \
            CC="gcc -Wno-error=incompatible-pointer-types -Wno-error=implicit-function-declaration -Wno-error=int-conversion" &&
        make -j"$(nproc)"
    ) >>"$blog" 2>&1 && build_ok=1

    if [ "$build_ok" = 1 ]; then
        mkdir -p "$bindir"
        cp "$src/obj/bird" "$src/obj/birdcl" "$bindir/" || build_ok=0
    fi

    git -C "$REPO" worktree remove --force "$src" 2>/dev/null
    git -C "$REPO" worktree prune

    if [ "$build_ok" != 1 ]; then
        say "BUILD FAILED — see $blog"
        exit 125
    fi
fi

# ------------------------------------------------------------------ run ----
run=$(mktemp -d) || exit 125
bird_pid=
cleanup() {
    [ -n "$bird_pid" ] && kill "$bird_pid" 2>/dev/null && wait "$bird_pid" 2>/dev/null
    rm -rf "$run"
}
trap cleanup EXIT

# Same as route-leak.conf, but logs to a file instead of syslog/stderr.
cat >"$run/bird.conf" <<EOF
log "$run/bird.log" all;
router id 1.2.3.4;

debug protocols { states };
debug channels { routes, states };
debug tables { routes, states };

timeformat base         iso long;
timeformat log          iso long;
timeformat protocol     iso long;
timeformat route        iso long;

protocol device {
    scan time 10;
}

ipv4 table generated_t;

protocol static generated {
  ipv4 {
    table generated_t;
};
  check link;
  igp table master4;
  route 0.0.0.0/0 recursive 8.8.8.8 {
    preference=500;
};
  route 0.0.0.0/0 recursive 1.1.1.1 {
    preference=290;
};
  route 0.0.0.0/0 recursive 13.96.0.1 {
    preference=280;
};
  route 0.0.0.0/0 recursive 142.250.0.1 {
    preference=270;
};
  route 0.0.0.0/0 recursive 18.244.0.1 {
    preference=260;
};
};

protocol pipe pipe_generated  {
  table master4;
  peer table generated_t;
  export none;
  import all;
};
EOF

sock="$run/bird.ctl"
"$bindir/bird" -d -c "$run/bird.conf" -s "$sock" >"$run/stderr.log" 2>&1 &
bird_pid=$!

# wait for the control socket
for _ in $(seq 1 50); do
    [ -S "$sock" ] && break
    kill -0 "$bird_pid" 2>/dev/null || {
        say "bird died on startup:"; tail -5 "$run/stderr.log" >&2; exit 125; }
    sleep 0.2
done
[ -S "$sock" ] || { say "control socket never appeared"; exit 125; }

# Number of updates is measured two ways:
#  - the pipe's "Import updates" counter from birdcl, where it works;
#  - the number of route-update trace lines in the debug log ("debug channels
#    { routes }" logs each update). Needed because mid-branch v3 commits had
#    broken CLI stats counters that read 0 while routes were churning (fixed
#    only in da838bca0 "CLI: fix channel stats display").
# A good build converges and goes quiet; a bad build re-propagates the same
# routes thousands of times per second.
loglines() {
    wc -l <"$run/bird.log" 2>/dev/null || echo 0
}
counter() {
    "$bindir/birdcl" -s "$sock" show protocols all pipe_generated 2>/dev/null \
        | awk '/Import updates:/ { print $3; exit }'
}

sleep "$SETTLE"
c1=$(counter); l1=$(loglines)
sleep "$GAP"
c2=$(counter); l2=$(loglines)
mem=$("$bindir/birdcl" -s "$sock" show memory 2>/dev/null | awk '/Total:/ { print $2, $3; exit }')

if ! kill -0 "$bird_pid" 2>/dev/null; then
    say "bird died during test:"; tail -5 "$run/stderr.log" >&2; exit 125
fi

lgrowth=$((l2 - l1))

case "$c1$c2" in
    *[!0-9]*|"") cdelta=; cinfo="import updates n/a" ;;
    *) cdelta=$((c2 - c1)); cinfo="import updates $c1 -> $c2 (+$cdelta)" ;;
esac

# a quiet bird logs a handful of lines between samples (device scan);
# a churning one writes thousands of update traces per second
BAD_LOG_LINES=${BAD_LOG_LINES:-200}
verdict_bad=0
[ "$lgrowth" -ge "$BAD_LOG_LINES" ] && verdict_bad=1
[ -n "$cdelta" ] && [ "$cdelta" -ge "$BAD_DELTA" ] && verdict_bad=1
if [ "$verdict_bad" = 1 ]; then
    say "BAD   log +$lgrowth lines in ${GAP}s, $cinfo, mem $mem"
    exit 1
else
    say "GOOD  log +$lgrowth lines in ${GAP}s, $cinfo, mem $mem"
    exit 0
fi
