Add live regression test for growing-file transfers, bump grace period to 1s

scripts/regression_test.sh exercises both writer behaviors -- normal
append-only growth (the original, must-keep-working case) and preallocated
growth (ftruncate-extend then shrink-and-rename, the case that caused the
hang/corruption fixed in the previous commit) -- in both transfer
directions, against a real ssh test host with a real capture file sliced to
a record-aligned prefix. scripts/pcap_offsets.py computes record-boundary
cut points for staging realistic growth.

Bump FINALIZE_GRACE_PERIOD from 500ms to 1s: a comfortable margin even for
a writer whose header and payload writes aren't atomic -- if the header
landed, the body should follow shortly.
This commit is contained in:
2026-08-27 23:12:29 -04:00
parent aa374bc66e
commit f43a94a7d6
3 changed files with 300 additions and 2 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Compute record-boundary-aligned byte offsets in a classic (non-pcapng)
.pcap file, for simulating a still-growing capture in regression tests.
Usage: pcap_offsets.py <file> <n_segments>
Prints n_segments+1 offsets, one per line: 0 (start of body, i.e. just past
the 24-byte global header) ... file size (end), each guaranteed to land
exactly on a record boundary. Segment sizes are as even as possible by
record count. Exits nonzero with a message on stderr if the file isn't a
well-formed classic pcap (e.g. truncated mid-record, or pcapng).
"""
import struct
import sys
MAGICS = {
0xA1B2C3D4: ("<", False), # little-endian, microsecond
0xA1B23C4D: ("<", False), # little-endian, nanosecond (same header shape)
0xD4C3B2A1: (">", False), # big-endian, microsecond
0x4D3CB2A1: (">", False), # big-endian, nanosecond
}
def record_offsets(path):
with open(path, "rb") as f:
data = f.read()
if len(data) < 24:
sys.exit(f"file too short to contain a pcap global header: {path}")
magic = struct.unpack("<I", data[0:4])[0]
if magic not in MAGICS:
sys.exit(f"unrecognized pcap magic {magic:#010x} in {path} (pcapng is not supported)")
endian, _ = MAGICS[magic]
offsets = [24]
pos = 24
while pos < len(data):
if pos + 16 > len(data):
sys.exit(f"file ends mid-record-header at offset {pos}: {path}")
incl_len = struct.unpack(endian + "I", data[pos + 8 : pos + 12])[0]
record_total = 16 + incl_len
if pos + record_total > len(data):
sys.exit(f"file ends mid-record-payload at offset {pos} (declared len {incl_len}): {path}")
pos += record_total
offsets.append(pos)
return offsets
def main():
if len(sys.argv) != 3:
sys.exit(f"usage: {sys.argv[0]} <file> <n_segments>")
path = sys.argv[1]
n = int(sys.argv[2])
if n < 1:
sys.exit("n_segments must be >= 1")
offsets = record_offsets(path) # offsets[0]=24 (after header), offsets[-1]=file size
n_records = len(offsets) - 1
if n_records < n:
sys.exit(f"file only has {n_records} records, can't split into {n} segments")
# n+1 cumulative-size boundaries, evenly spaced by record index, from
# "header only" (offsets[0] == 24) up to the whole file (offsets[-1]).
for i in range(0, n + 1):
record_idx = (n_records * i) // n
print(offsets[record_idx])
if __name__ == "__main__":
main()
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env bash
# Regression test for scpcap's growing-file tailer, run against a real
# remote host over ssh. Exercises two distinct writer behaviors, in both
# transfer directions, so the tailer is tested wherever it actually runs
# (locally, or in the remote --server process):
#
# 1. "normal growth": a well-behaved writer that only ever appends bytes
# (file length always equals real content) -- the original behavior
# this tool was built for. Must keep working.
#
# 2. "preallocated growth": a writer that reserves space ahead of its
# real write cursor (ftruncate-extend, reading back as zero) and fills
# it in place, only shrinking the file back down to the real size at
# rotation, immediately before renaming to final. This is the pattern
# that caused a hang + corrupted destination before the confirmed_len
# fix in src/chunker.rs / src/tail.rs -- see git log for the writeup.
#
# Requires: a real pcap file to source test data from (default: the first
# *.pcap file found in ~/pcap), and ssh access to a test host with an
# scpcap binary deployable to ~/bin/scpcap (this script builds and deploys
# it before testing).
#
# Usage: scripts/regression_test.sh [pcap-file] [user@host]
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC_PCAP="${1:-$(ls "$HOME"/pcap/*.pcap 2>/dev/null | head -1)}"
HOST="${2:-eric@locl.sh}"
REMOTE_EXE="bin/scpcap"
BIN="$REPO_ROOT/target/release/scpcap"
LOCAL_TEST="$HOME/scpcap_test"
REMOTE_TEST="scpcap_test"
PAD_BYTES=65536
STAGE_DELAY=0.3
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
PASS=0
FAIL=0
log() { echo "[*] $*" >&2; }
pass() { echo " PASS: $*"; PASS=$((PASS + 1)); }
fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); }
if [ -z "$SRC_PCAP" ] || [ ! -f "$SRC_PCAP" ]; then
echo "no source pcap file found (looked in ~/pcap/*.pcap); pass one explicitly" >&2
exit 2
fi
log "source capture: $SRC_PCAP ($(wc -c < "$SRC_PCAP") bytes)"
log "building release binary"
(cd "$REPO_ROOT" && cargo build --release --quiet) || exit 1
log "deploying to $HOST:~/$REMOTE_EXE"
scp -q "$BIN" "$HOST:$REMOTE_EXE" || exit 1
mkdir -p "$LOCAL_TEST/dest"
ssh "$HOST" "mkdir -p $REMOTE_TEST/dest"
# --- Build a small, record-aligned test capture -----------------------------
TRUNCATED_SRC="$WORK/src.pcap"
head -c 8000000 "$SRC_PCAP" > "$WORK/src.raw" 2>/dev/null || cp "$SRC_PCAP" "$WORK/src.raw"
PREFIX_LEN=$(python3 - "$WORK/src.raw" <<'PYEOF'
import struct, sys
with open(sys.argv[1], "rb") as f:
data = f.read()
magic = struct.unpack("<I", data[0:4])[0]
endian = "<" if magic in (0xA1B2C3D4, 0xA1B23C4D) else ">"
pos = 24
while pos + 16 <= len(data):
incl_len = struct.unpack(endian + "I", data[pos + 8: pos + 12])[0]
total = 16 + incl_len
if pos + total > len(data):
break
pos += total
print(pos)
PYEOF
)
dd if="$WORK/src.raw" of="$TRUNCATED_SRC" bs=1 count="$PREFIX_LEN" status=none
log "test capture: $TRUNCATED_SRC ($(wc -c < "$TRUNCATED_SRC") bytes, record-aligned)"
N_SEGMENTS=4
mapfile -t OFFSETS < <(python3 "$REPO_ROOT/scripts/pcap_offsets.py" "$TRUNCATED_SRC" "$N_SEGMENTS")
FINAL_SIZE="${OFFSETS[-1]}"
log "growth stages (cumulative bytes): ${OFFSETS[*]}"
sha() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; }
EXPECTED_SHA="$(sha "$TRUNCATED_SRC")"
# --- Target primitives: "local:<path>" or "remote:<path>" -------------------
t_write() { # target prev len
local target="$1" prev="$2" len="$3"
case "$target" in
local:*)
local path="${target#local:}"
python3 -c "
with open('$TRUNCATED_SRC','rb') as s, open('$path','r+b') as d:
s.seek($prev); d.seek($prev); d.write(s.read($len))
"
;;
remote:*)
local path="${target#remote:}"
dd if="$TRUNCATED_SRC" bs=1 skip="$prev" count="$len" status=none | \
ssh "$HOST" "python3 -c \"
import sys
with open('$path','r+b') as f:
f.seek($prev)
f.write(sys.stdin.buffer.read())
\""
;;
esac
}
t_set_len() { # target len
local target="$1" len="$2"
case "$target" in
local:*) python3 -c "open('${target#local:}','r+b').truncate($len)" ;;
remote:*) ssh "$HOST" "python3 -c \"open('${target#remote:}','r+b').truncate($len)\"" ;;
esac
}
t_create_empty() { # target
local target="$1"
case "$target" in
local:*) : > "${target#local:}" ;;
remote:*) ssh "$HOST" ": > ${target#remote:}" ;;
esac
}
t_rename() { # target_partial target_final
local partial="$1" final="$2"
case "$partial" in
local:*) mv "${partial#local:}" "${final#local:}" ;;
remote:*) ssh "$HOST" "mv ${partial#remote:} ${final#remote:}" ;;
esac
}
# --- Test runner --------------------------------------------------------------
# $1 = name, $2 = "normal" | "preallocated", $3 = "local-to-remote" | "remote-to-local"
run_case() {
local name="$1" mode="$2" direction="$3"
log "=== $name ($direction, $mode) ==="
if [ "$direction" = "local-to-remote" ]; then
local partial="local:$LOCAL_TEST/$name.pcap.partial"
local final="local:$LOCAL_TEST/$name.pcap"
local dest="$REMOTE_TEST/$name.pcap"
rm -f "$LOCAL_TEST/$name.pcap.partial" "$LOCAL_TEST/$name.pcap"
ssh "$HOST" "rm -f $dest $dest.partial"
t_create_empty "$partial"
t_write "$partial" 0 "${OFFSETS[0]}"
# For preallocated growth, reserve the padded size right after the
# first real write, before the client ever gets a chance to look.
if [ "$mode" = preallocated ]; then
t_set_len "$partial" $((FINAL_SIZE + PAD_BYTES))
fi
"$BIN" --remote-exe "$REMOTE_EXE" "$LOCAL_TEST/$name.pcap.partial" "$HOST:$REMOTE_TEST/" &
local client_pid=$!
local prev="${OFFSETS[0]}"
for off in "${OFFSETS[@]:1}"; do
sleep "$STAGE_DELAY"
t_write "$partial" "$prev" $((off - prev))
prev="$off"
done
if [ "$mode" = preallocated ]; then
sleep "$STAGE_DELAY"
t_set_len "$partial" "$FINAL_SIZE"
fi
t_rename "$partial" "$final"
wait "$client_pid"
local status=$?
local actual_sha
actual_sha="$(ssh "$HOST" "sha256sum $dest 2>/dev/null | awk '{print \$1}'" || true)"
else
local partial="remote:$REMOTE_TEST/$name.pcap.partial"
local final="remote:$REMOTE_TEST/$name.pcap"
local dest="$LOCAL_TEST/dest/$name.pcap"
ssh "$HOST" "rm -f $REMOTE_TEST/$name.pcap $REMOTE_TEST/$name.pcap.partial"
rm -f "$dest" "$dest.partial"
t_create_empty "$partial"
t_write "$partial" 0 "${OFFSETS[0]}"
if [ "$mode" = preallocated ]; then
t_set_len "$partial" $((FINAL_SIZE + PAD_BYTES))
fi
"$BIN" --remote-exe "$REMOTE_EXE" "$HOST:$REMOTE_TEST/$name.pcap.partial" "$LOCAL_TEST/dest/" &
local client_pid=$!
local prev="${OFFSETS[0]}"
for off in "${OFFSETS[@]:1}"; do
sleep "$STAGE_DELAY"
t_write "$partial" "$prev" $((off - prev))
prev="$off"
done
if [ "$mode" = preallocated ]; then
sleep "$STAGE_DELAY"
t_set_len "$partial" "$FINAL_SIZE"
fi
t_rename "$partial" "$final"
wait "$client_pid"
local status=$?
local actual_sha
actual_sha="$(sha "$dest" || true)"
fi
if [ "$status" -eq 0 ] && [ "$actual_sha" = "$EXPECTED_SHA" ]; then
pass "$name ($direction): exit 0, sha matches"
else
fail "$name ($direction): exit=$status expected_sha=$EXPECTED_SHA actual_sha=$actual_sha"
fi
}
run_case "normal_growth" normal "local-to-remote"
run_case "preallocated" preallocated "local-to-remote"
run_case "normal_growth_remote_src" normal "remote-to-local"
run_case "preallocated_remote_src" preallocated "remote-to-local"
echo
echo "=== summary: $PASS passed, $FAIL failed ==="
exit $((FAIL > 0))