#!/bin/bash
# Minimal reproducer for the drop_progress-zeroing bug.
#
# One paragraph: btrfs_drop_snapshot() resumes a snapshot delete by copying
# the saved key into wc->update_progress only. wc->drop_progress/drop_level
# stay zero and get written back on every checkpoint. If the resumed run
# hits a checkpoint (commit or umount) before it finishes dropping a
# reference itself, the on-disk drop key goes to (0 0 0) level 0 while the
# root item still exists. The next mount then restarts the delete from
# slot 0, finds some children at refs 1 (the other snapshot's ref only,
# S1's own ref already dropped earlier), and tries to free them as if S1
# still owned them alone: unable to find ref / Filesystem corrupted, with
# backref mismatches surviving in `btrfs check`.
#
# This script drives exactly that: build S1 (+ snapshot S2), delete S1,
# interrupt the delete twice (steps 3 and 4 below), then do an uninterrupted
# third mount and look for the abort. It needs two mount/umount cycles
# timed around the delete, because that's what the bug needs -- there is
# no shortcut that skips the interruption and still exercises the defect.
#
# Parameters (100k files / 2G image) are the smallest that have reproduced
# this reliably in testing. A plain loop device
# finishes the walk from page cache in well under the time it takes to
# mount/unmount, so this layers a dm-delay device on top to slow reads/
# writes down to something an mount/umount cycle can actually land inside.
#
# Usage (needs root; does not sudo itself):
#   ./repro-simple.sh [WORK_DIR]     default: ./work-simple
# Exit status: 0 bug reproduced (abort/check errors); 1 fs behaved correctly
# (no zeroing, drop completed, check clean, S2 intact: expected when patched);
# 2 inconclusive / setup failure. Redirect output yourself to keep a log, e.g.:
#   ./repro-simple.sh work-a 2>&1 | tee work-a.log

set -uo pipefail

WORK_DIR=${1:-"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/work-simple"}
NFILES=100000
IMAGE_SIZE=2G
NODESIZE=4096          # minimum; keeps the tree tall/wide for a given file count
READ_DELAY_MS=15
WRITE_DELAY_MS=5
POLL_DELAY=5            # seconds between step 3's polls
POLL_RETRIES=30
STEP4_MAX_ATTEMPTS=20
STEP5_MAX_WAIT=300
MKFILES="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mkfiles.py"
MANIFEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/manifest.py"
IMG="$WORK_DIR/test.img"
MNT="$WORK_DIR/mnt"
DMNAME="btrfs-hunt-simple-$$"

log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
die() { log "FATAL: $*"; exit 2; }

[ "$(id -u)" -eq 0 ] || die "run as root: losetup/mkfs/mount/btrfs-check need it"
for bin in losetup mkfs.btrfs btrfs truncate python3 awk dmsetup blockdev; do
    command -v "$bin" >/dev/null || die "missing required tool: $bin"
done

LOOPDEV="" DEV=""
cleanup() {
    local rc=$?
    mountpoint -q "$MNT" 2>/dev/null && { umount "$MNT" 2>/dev/null || umount -l "$MNT" 2>/dev/null; }
    dmsetup remove "$DMNAME" 2>/dev/null || true   # must go before the loop device under it
    [ -n "$LOOPDEV" ] && losetup -d "$LOOPDEV" 2>/dev/null || true
    exit "$rc"
}
trap cleanup EXIT INT TERM

drop_caches() { sync; echo 3 >/proc/sys/vm/drop_caches 2>/dev/null || true; }

# drop_state -> "OBJECTID LEVEL" of S1's drop_progress key, or "ABSENT" if
# S1's root item is gone (the delete finished). Read from the unmounted
# on-disk tree, the same state the bug itself acts on.
drop_state() {
    btrfs inspect-internal dump-tree -t root "$DEV" 2>/dev/null | awk -v want="$S1_ID" '
        /item [0-9]+ key \(/ { split($0, a, "("); split(a[2], b, " "); cur = b[1]; next }
        cur == want && /drop_progress key/ { print; found=1 }
        END { if (!found) print "ABSENT" }' |
    { read -r line
      [ "$line" = ABSENT ] && { echo ABSENT; return; }
      echo "$(grep -oE 'key \([0-9]+' <<<"$line" | grep -oE '[0-9]+') $(grep -oE 'drop_level [0-9]+' <<<"$line" | grep -oE '[0-9]+')"
    }
}

root_level() {
    btrfs inspect-internal dump-tree -t root "$DEV" 2>/dev/null | awk -v want="$S1_ID" '
        /item [0-9]+ key \(/ { split($0, a, "("); split(a[2], b, " "); cur = b[1]; next }
        cur == want && /^[[:space:]]*level [0-9]+ generation_v2/ { match($0, /level [0-9]+/); print substr($0, RSTART+6, RLENGTH-6); exit }'
}

# ---- step 1: build S1 (nfiles under it), snapshot to S2, delete S1 -------

log "step 1: $IMAGE_SIZE image, mkfs -n $NODESIZE, loop + dm-delay (read=${READ_DELAY_MS}ms write=${WRITE_DELAY_MS}ms)"
mkdir -p "$WORK_DIR" "$MNT"
truncate -s "$IMAGE_SIZE" "$IMG"
LOOPDEV=$(losetup -f --show "$IMG") || die "losetup failed"
modprobe dm-delay 2>/dev/null || true
SECTORS=$(blockdev --getsz "$LOOPDEV") || die "blockdev --getsz failed"
dmsetup create "$DMNAME" --table "0 $SECTORS delay $LOOPDEV 0 $READ_DELAY_MS $LOOPDEV 0 $WRITE_DELAY_MS" \
    || die "dmsetup create failed"
DEV="/dev/mapper/$DMNAME"

mkfs.btrfs -f -n "$NODESIZE" -L reprosimple "$DEV" >/dev/null || die "mkfs.btrfs failed"
mount -o max_inline=0,noatime "$DEV" "$MNT" || die "mount failed"

log "step 1: populate S1 with $NFILES files, snapshot -r to S2 (S2 now shares every S1 block)"
btrfs subvolume create "$MNT/S1" >/dev/null || die "subvolume create failed"
mkdir -p "$MNT/S1/data"
python3 -I "$MKFILES" "$MNT/S1/data" "$NFILES" >/dev/null || die "mkfiles.py failed"
btrfs filesystem sync "$MNT" >/dev/null
python3 -I "$MANIFEST" "$MNT/S1" >"$WORK_DIR/s1.manifest" || die "manifest of S1 failed"   # warm cache, noatime: no COW
S1_ID=$(btrfs inspect-internal rootid "$MNT/S1") || die "rootid failed"
btrfs subvolume snapshot -r "$MNT/S1" "$MNT/S2" >/dev/null || die "snapshot failed"
btrfs filesystem sync "$MNT" >/dev/null

S1_LEVEL=$(root_level)
log "S1 id=$S1_ID root level=${S1_LEVEL:-?} (want >=2)"
[ -n "$S1_LEVEL" ] && [ "$S1_LEVEL" -ge 2 ] || die "S1's root is too shallow (level ${S1_LEVEL:-?}); rerun or raise NFILES"

log "step 2: delete S1 (S2 keeps every one of S1's root children referenced)"
btrfs subvolume delete "$MNT/S1" >/dev/null || die "subvolume delete failed"
btrfs filesystem sync "$MNT" >/dev/null

# ---- step 3: first drop run, interrupted after >=1 child -----------------
#
# Step 3: each finished child advances the on-disk drop key to a
# non-zero slot at S1's root level. Poll by forcing a checkpoint (sync) and
# reading that key back; stop and unmount as soon as it moves.

log "step 3: sync-poll for a non-zero drop key at root level $S1_LEVEL, then unmount"
PREV_OBJECTID=""
for i in $(seq 1 "$POLL_RETRIES"); do
    btrfs filesystem sync "$MNT" >/dev/null 2>&1
    read -r objectid level <<<"$(drop_state)"
    [ "$objectid" = ABSENT ] && die "S1's drop finished inside one sync interval; tree too small/fast to interrupt"
    if [ "$objectid" != 0 ] && [ "$level" = "$S1_LEVEL" ]; then
        PREV_OBJECTID=$objectid
        log "poll $i: drop key ($objectid ...) level $level -- child dropped, unmounting"
        umount "$MNT" || die "umount failed (step 3)"
        break
    fi
    drop_caches
    sleep "$POLL_DELAY"
    [ "$i" -eq "$POLL_RETRIES" ] && die "drop key still zero after $POLL_RETRIES polls"
done

# ---- step 4: resumed run, interrupted before its first reference drop ----
#
# Step 4: the defect. A resumed run that hits a checkpoint before
# finishing its resume child writes drop key (0 0 0) level 0 while the root
# item still exists. Catch that by mounting and immediately unmounting
# (each umount is a checkpoint) until the key goes non-zero -> zero.

log "step 4: resume/interrupt cycles, looking for drop key to go non-zero -> (0 0)"
BUG_SEEN=0
for attempt in $(seq 1 "$STEP4_MAX_ATTEMPTS"); do
    mount -o max_inline=0,noatime "$DEV" "$MNT" || die "remount failed (step 4, attempt $attempt)"
    umount "$MNT" || die "umount failed (step 4, attempt $attempt)"
    drop_caches   # cold cache for the next resume walk, or it finishes from RAM before this cycle lands
    read -r objectid level <<<"$(drop_state)"
    [ "$objectid" = ABSENT ] && { log "attempt $attempt: S1 already gone; drop finished without zeroing. Inconclusive."; break; }
    log "attempt $attempt: drop key ($objectid ...) level $level"
    if [ "$objectid" = 0 ] && [ -n "$PREV_OBJECTID" ] && [ "$PREV_OBJECTID" != 0 ]; then
        log "BUG REPRODUCED: drop key went from ($PREV_OBJECTID ...) to (0 0) while S1's root item still exists"
        BUG_SEEN=1
        break
    fi
    PREV_OBJECTID=$objectid
done
# On a patched module the key stays at the saved resume point; go on to
# step 5 anyway, which must then finish the drop with no abort, check clean.
[ "$BUG_SEEN" = 1 ] || log "step 4: drop key never went to zero in $STEP4_MAX_ATTEMPTS cycles (expected on a patched module)"

# ---- step 5: uninterrupted restart -> abort / corruption ------------------
#
# Step 5: if the drop key is zero, this mount restarts the delete
# from slot 0. The children step 3 finished are now at refs 1 (S2's ref
# only); the walk treats them as solely owned and tries to free them again, aborting the
# transaction. Oracles: dmesg abort signature, then `btrfs check --readonly`.

log "step 5: uninterrupted mount, wait up to ${STEP5_MAX_WAIT}s for completion or abort"
MARK="btrfs-hunt simple $$ $(date +%s%N)"
echo "$MARK" >/dev/kmsg 2>/dev/null || true
DEVNAME=$(basename "$(readlink -f "$DEV")")
my_dmesg() { dmesg 2>/dev/null | awk -v m="$MARK" 'f; index($0, m) {f=1}' | grep -E "device $DEVNAME[ )]"; }

DROP_DONE=0
mount -o max_inline=0,noatime "$DEV" "$MNT" || die "mount failed (step 5)"
drop_caches
t=0
while [ "$t" -lt "$STEP5_MAX_WAIT" ]; do
    if ! mountpoint -q "$MNT"; then log "fs no longer mounted (forced read-only) after ${t}s"; break; fi
    if my_dmesg | grep -qE 'unable to find ref byte nr|Filesystem corrupted'; then
        log "abort signature in dmesg after ${t}s"; sleep 2; break
    fi
    [ "$(drop_state)" = ABSENT ] && { log "S1's root item is gone after ${t}s -- drop completed, no abort"; DROP_DONE=1; break; }
    drop_caches; sleep 1; t=$((t + 1))
done
umount "$MNT" 2>/dev/null || true

my_dmesg >"$WORK_DIR/dmesg-step5.log" || true
ABORTED=0
if my_dmesg | grep -qE 'unable to find ref byte nr|Filesystem corrupted|aborted'; then
    log "RESULT: kernel abort matches the incident signature (full log: $WORK_DIR/dmesg-step5.log)"
    ABORTED=1
else
    log "RESULT: no abort in dmesg (full log: $WORK_DIR/dmesg-step5.log)"
fi

btrfs check --readonly "$DEV" >"$WORK_DIR/check.log" 2>&1
CHECK_RC=$?
log "btrfs check --readonly rc=$CHECK_RC (full log: $WORK_DIR/check.log)"
CHECK_BAD=0
if grep -qE 'ref mismatch|has no backref item|transid verify failed|errors found' "$WORK_DIR/check.log"; then
    log "RESULT: check reports mismatches matching the incident"
    CHECK_BAD=1
else
    log "RESULT: check clean"
fi

# ---- S2 content check: S2 must still match S1's pre-snapshot manifest ---
# Plain loop device (no dm-delay, so the read is fast), mounted ro,noatime
# so reading S2 cannot COW it and no cleaner runs.
dmsetup remove "$DMNAME" || die "dmsetup remove failed"
S2_OK=0
if mount -o ro,noatime "$LOOPDEV" "$MNT"; then
    python3 -I "$MANIFEST" "$MNT/S2" >"$WORK_DIR/s2.manifest" 2>"$WORK_DIR/s2.manifest.err"
    umount "$MNT"
    cmp -s "$WORK_DIR/s1.manifest" "$WORK_DIR/s2.manifest" && S2_OK=1
fi
log "S2 content: $([ "$S2_OK" = 1 ] && echo identical to S1 before the snapshot || echo DIFFERS or unreadable) ($(wc -l <"$WORK_DIR/s1.manifest") entries; $WORK_DIR/s2.manifest)"

log "summary: zeroed=$BUG_SEEN aborted=$ABORTED check_bad=$CHECK_BAD drop_done=$DROP_DONE s2_ok=$S2_OK"
if [ "$ABORTED" = 1 ] || [ "$CHECK_BAD" = 1 ]; then
    log "VERDICT: bug reproduced"
    exit 0
elif [ "$BUG_SEEN" = 0 ] && [ "$DROP_DONE" = 1 ] && [ "$S2_OK" = 1 ]; then
    log "VERDICT: no zeroing, drop completed, no abort, check clean, S2 intact (expected on a patched module)"
    exit 1
else
    log "VERDICT: inconclusive (zeroed=$BUG_SEEN, drop_done=$DROP_DONE)"
    exit 2
fi
