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:
Executable
+68
@@ -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()
|
||||
Executable
+228
@@ -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))
|
||||
Reference in New Issue
Block a user