Compare commits

...

1 Commits

Author SHA1 Message Date
Xavier Roche
63671fb4cf Stop the 412/416 partial-reget loop on continue/update (#206)
On resume, the Range request is rebuilt by back_add from a temp-ref keyed on
(adr,fil) that records the partial download's real save name. A 412/416
("Range Not Satisfiable") means that partial is stale and the whole file
must be re-fetched. The handler only removed heap->sav, so when the resume
pass recomputed a save name different from the temp-ref's (the default
delayed-type machinery renames freely), the partial was never cleared:
back_add re-sent the same Range, earned the same 416, and the link was
re-recorded forever, growing the scan counter without bound.

Clear the whole partial wherever it lives -- the temp-ref and the file it
points at, plus heap->sav -- so the re-record falls through to a plain full
GET. Re-get only when there was a partial to discard and both Range triggers
(the ref and the on-disk file) are actually gone; once they are, a fresh 416
with nothing left to drop means the whole-file GET itself failed, so the link
gives up cleanly instead of re-queueing. A failed removal (read-only or full
cache) also gives up rather than looping, since back_add would otherwise
re-Range the surviving ref; url_savename_refname_remove now reports the
removal result so the handler can tell. (The request's range_used flag would
be the natural one-shot signal, but it does not survive the delayed-type
two-pass, so we key off the partial instead.)

tests/20_local-resume-loop.test drives it offline: pass 1 is interrupted
(SIGTERM, so the exit handler finalizes the cache and the temp-ref) to leave
a partial, then pass 2 --continue gets 416 on every resume request. A
portable watchdog kills pass 2 if it loops; the test asserts it terminates
and attempts exactly one whole-file re-get (2 <= requests <= 8). It fails on
the pre-fix handler (loops) and on a re-get that silently drops the link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
2026-06-24 21:06:05 +02:00
6 changed files with 203 additions and 32 deletions

View File

@@ -1729,10 +1729,10 @@ char *url_savename_refname_fullpath(httrackp * opt, const char *adr,
StringBuff(opt->path_log), digest_filename); StringBuff(opt->path_log), digest_filename);
} }
/* remove refname if any */ /* remove refname if any; HTS_TRUE if it was removed */
void url_savename_refname_remove(httrackp * opt, const char *adr, hts_boolean url_savename_refname_remove(httrackp *opt, const char *adr,
const char *fil) { const char *fil) {
char *filename = url_savename_refname_fullpath(opt, adr, fil); char *filename = url_savename_refname_fullpath(opt, adr, fil);
(void) UNLINK(filename); return UNLINK(filename) == 0 ? HTS_TRUE : HTS_FALSE;
} }

View File

@@ -104,8 +104,9 @@ char *url_md5(char *digest_buffer, const char *fil_complete);
void url_savename_refname(const char *adr, const char *fil, char *filename); void url_savename_refname(const char *adr, const char *fil, char *filename);
char *url_savename_refname_fullpath(httrackp * opt, const char *adr, char *url_savename_refname_fullpath(httrackp * opt, const char *adr,
const char *fil); const char *fil);
void url_savename_refname_remove(httrackp * opt, const char *adr, /* Remove the temp-ref for (adr,fil); HTS_TRUE if it was removed. */
const char *fil); hts_boolean url_savename_refname_remove(httrackp *opt, const char *adr,
const char *fil);
#endif #endif
#endif #endif

View File

@@ -3749,44 +3749,60 @@ int hts_mirror_check_moved(htsmoduleStruct * str,
} // bloc } // bloc
// erreur HTTP (ex: 404, not found) // erreur HTTP (ex: 404, not found)
} else if ((r->statuscode == HTTP_PRECONDITION_FAILED) } else if ((r->statuscode == HTTP_PRECONDITION_FAILED) ||
|| (r->statuscode == HTTP_REQUESTED_RANGE_NOT_SATISFIABLE) (r->statuscode == HTTP_REQUESTED_RANGE_NOT_SATISFIABLE)) {
) { // Precondition Failed, c'est à dire pour nous redemander TOUT le fichier // 412/416: the resume partial is stale; re-get the whole file (#206)
if (fexist_utf8(heap(ptr)->sav)) { lien_back *itemback = NULL;
remove(heap(ptr)->sav); // Eliminer int had_partial = 0;
} else { int ref_existed = 0;
hts_log_print(opt, LOG_WARNING, int ref_gone;
"Unexpected 412/416 error (%s) for %s%s, '%s' could not be found on disk",
r->msg, urladr(), urlfil(), // Drop the temp-ref, its partial, and heap->sav so the re-get carries no
heap(ptr)->sav != NULL ? heap(ptr)->sav : ""); // Range; else back_add rebuilds the same Range and loops.
if (back_unserialize_ref(opt, heap(ptr)->adr, heap(ptr)->fil,
&itemback) == 0) {
had_partial = 1;
ref_existed = 1;
// best-effort: an orphaned partial cannot re-Range once the ref is gone
if (fexist_utf8(itemback->url_sav))
(void) UNLINK(fconv(OPT_GET_BUFF(opt), OPT_GET_BUFF_SIZE(opt),
itemback->url_sav));
back_clear_entry(itemback);
freet(itemback);
} }
if (!fexist_utf8(heap(ptr)->sav)) { // Bien éliminé? (sinon on boucle..) // don't re-record if the ref survived (it would re-Range and loop)
#if HDEBUG ref_gone =
printf("Partial content NOT up-to-date, reget all file for %s\n", url_savename_refname_remove(opt, heap(ptr)->adr, heap(ptr)->fil) ||
heap(ptr)->sav); !ref_existed;
#endif if (fexist_utf8(heap(ptr)->sav)) {
had_partial = 1;
remove(heap(ptr)->sav);
}
// Re-get once, only if a partial existed and both Range triggers are
// gone; a failed removal gives up rather than looping. range_used is
// unreliable (it does not survive the delayed-type two-pass).
if (had_partial && ref_gone && !fexist_utf8(heap(ptr)->sav)) {
hts_log_print(opt, LOG_DEBUG, "Partial file reget (%s) for %s%s", hts_log_print(opt, LOG_DEBUG, "Partial file reget (%s) for %s%s",
r->msg, urladr(), urlfil()); r->msg, urladr(), urlfil());
// enregistrer le MEME lien
if (hts_record_link(opt, heap(ptr)->adr, heap(ptr)->fil, heap(ptr)->sav, "", "", NULL)) { if (hts_record_link(opt, heap(ptr)->adr, heap(ptr)->fil, heap(ptr)->sav, "", "", NULL)) {
heap_top()->testmode = heap(ptr)->testmode; // mode test? heap_top()->testmode = heap(ptr)->testmode;
heap_top()->link_import = 0; // pas mode import heap_top()->link_import = 0;
heap_top()->depth = heap(ptr)->depth; heap_top()->depth = heap(ptr)->depth;
heap_top()->pass2 = max(heap(ptr)->pass2, numero_passe); heap_top()->pass2 = max(heap(ptr)->pass2, numero_passe);
heap_top()->retry = heap(ptr)->retry; heap_top()->retry = heap(ptr)->retry;
heap_top()->premier = heap(ptr)->premier; heap_top()->premier = heap(ptr)->premier;
heap_top()->precedent = ptr; heap_top()->precedent = ptr;
//
// canceller lien actuel
error = 1; error = 1;
hts_invalidate_link(opt, ptr); // invalidate hashtable entry hts_invalidate_link(opt, ptr); // invalidate hashtable entry
// } else { // out of memory
} else { // oups erreur, plus de mémoire!! XH_uninit;
XH_uninit; // désallocation mémoire & buffers
return 0; return 0;
} }
} else { } else {
hts_log_print(opt, LOG_ERROR, "Can not remove old file %s", urlfil()); hts_log_print(opt, LOG_WARNING,
"Giving up on partial reget (%s) for %s%s", r->msg,
urladr(), urlfil());
error = 1; error = 1;
} }

113
tests/20_local-resume-loop.test Executable file
View File

@@ -0,0 +1,113 @@
#!/bin/bash
# Issue #206: a continue/update crawl looped forever when the resume Range got a
# 416. Pass 1 leaves a partial + temp-ref; pass 2 must terminate and not loop.
set -u
: "${top_srcdir:=..}"
testdir=$(cd "$(dirname "$0")" && pwd)
server="${testdir}/local-server.py"
command -v python3 >/dev/null || ! echo "python3 not found; skipping" || exit 77
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/httrack_206.XXXXXX") || exit 1
serverpid=
crawlpid=
cleanup() {
test -n "$crawlpid" && kill -9 "$crawlpid" 2>/dev/null
if test -n "$serverpid"; then
kill "$serverpid" 2>/dev/null
wait "$serverpid" 2>/dev/null
fi
rm -rf "$tmpdir"
}
trap cleanup EXIT HUP INT QUIT PIPE TERM
# --- start the server, discover its ephemeral port --------------------------
# RESUME_COUNTER gets a byte per /resume/blob.txt request (pass-2 delta bounds re-gets).
serverlog="${tmpdir}/server.log"
counter="${tmpdir}/blobcount"
RESUME_COUNTER="$counter" python3 "$server" --root "${testdir}/server-root" >"$serverlog" 2>&1 &
serverpid=$!
port=
for _ in $(seq 1 50); do
line=$(head -n1 "$serverlog" 2>/dev/null)
if test "${line%% *}" == "PORT"; then
port="${line#PORT }"
break
fi
kill -0 "$serverpid" 2>/dev/null || {
echo "server exited early: $(cat "$serverlog")"
exit 1
}
sleep 0.1
done
test -n "$port" || {
echo "could not discover server port"
exit 1
}
base="http://127.0.0.1:${port}"
which httrack >/dev/null || {
echo "could not find httrack"
exit 1
}
out="${tmpdir}/crawl"
mkdir "$out"
common=(-O "$out" --quiet --disable-security-limits --robots=0 --timeout=30 --retries=0)
refdir="${out}/hts-cache/ref"
# --- pass 1: crawl, interrupt once the blob download is underway -------------
printf '[pass 1: interrupt mid-download] ..\t'
httrack "${common[@]}" "${base}/resume/index.html" >"${tmpdir}/log1" 2>&1 &
crawlpid=$!
# Wait until blob.txt is requested, then SIGTERM so httrack's exit handler
# finalizes the cache and serializes the temp-ref.
for _ in $(seq 1 300); do
test -s "$counter" && break
kill -0 "$crawlpid" 2>/dev/null || break
sleep 0.1
done
sleep 0.5
kill -TERM "$crawlpid" 2>/dev/null
wait "$crawlpid" 2>/dev/null
crawlpid=
test -n "$(find "$refdir" -name '*.ref' 2>/dev/null)" || {
echo "FAIL: no temp-ref survived pass 1; cannot drive #206"
exit 1
}
echo "OK (temp-ref present)"
before=$(wc -c <"$counter" 2>/dev/null || echo 0)
# --- pass 2: --continue -> resume Range -> 416, bounded against the #206 loop -
# Kill pass 2 after a deadline (portable stand-in for `timeout`, absent on macOS).
printf '[pass 2: resume must terminate] ..\t'
HANG_RC=137 # 128 + SIGKILL
httrack "${common[@]}" --continue "${base}/resume/index.html" >"${tmpdir}/log2" 2>&1 &
crawlpid=$!
(sleep 30 && kill -9 "$crawlpid" 2>/dev/null) &
guard=$!
rc=0
wait "$crawlpid" 2>/dev/null || rc=$?
crawlpid=
kill "$guard" 2>/dev/null || true
wait "$guard" 2>/dev/null || true
if test "$rc" -eq "$HANG_RC"; then
echo "FAIL: pass 2 did not terminate (#206 resume->416 loop)"
exit 1
fi
echo "OK (terminated, rc=$rc)"
# The fix re-gets once (resume Range + range-less re-get = 2): the lower bound
# rejects a drop-the-link non-fix (1), the upper bound rejects the loop (many).
after=$(wc -c <"$counter" 2>/dev/null || echo 0)
hits=$((after - before))
printf '[bounded re-get count] ..\t'
if test "$hits" -lt 2; then
echo "FAIL: only ${hits} pass-2 request(s); the stale partial was not re-got"
exit 1
fi
if test "$hits" -gt 8; then
echo "FAIL: ${hits} pass-2 requests for blob.txt (resume is looping)"
exit 1
fi
echo "OK (${hits} requests)"

View File

@@ -59,6 +59,7 @@ TESTS = \
16_local-assume.test \ 16_local-assume.test \
17_local-empty-ct.test \ 17_local-empty-ct.test \
18_local-update.test \ 18_local-update.test \
19_local-connect-fallback.test 19_local-connect-fallback.test \
20_local-resume-loop.test
CLEANFILES = check-network_sh.cache CLEANFILES = check-network_sh.cache

View File

@@ -15,6 +15,7 @@ stdlib only (http.server + ssl) -- no new build or runtime dependency.
import argparse import argparse
import os import os
import time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import quote, unquote, urlsplit from urllib.parse import quote, unquote, urlsplit
@@ -176,6 +177,43 @@ class Handler(SimpleHTTPRequestHandler):
body, ctype = self.TYPE_MATRIX[path] body, ctype = self.TYPE_MATRIX[path]
self.send_raw(body, ctype) self.send_raw(body, ctype)
# resume / 416 loop (#206): the first GET stalls after a prefix so the crawl
# can be interrupted (partial + temp-ref); every later request is 416.
RESUME_PREFIX = b"PARTIAL-" + b"x" * 4096 # flushed before the stall
RESUME_LEN = len(RESUME_PREFIX) + 4096 # declared length never delivered
_resume_started = False
def route_resume_index(self):
self.send_html('\t<a href="blob.txt">blob</a>')
def route_resume(self):
counter = os.environ.get("RESUME_COUNTER")
if counter:
with open(counter, "a") as fp:
fp.write("x")
# First GET: stall mid-body so the crawl can be interrupted with a partial.
if not Handler._resume_started:
Handler._resume_started = True
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(self.RESUME_LEN))
self.send_header("Accept-Ranges", "bytes")
self.end_headers()
if self.command != "HEAD":
self.wfile.write(self.RESUME_PREFIX)
self.wfile.flush()
try:
while True:
time.sleep(3600)
except OSError:
pass
return
self.send_response(416, "Requested Range Not Satisfiable")
self.send_header("Content-Type", "image/png")
self.send_header("Content-Range", "bytes */%d" % self.RESUME_LEN)
self.send_header("Content-Length", "0")
self.end_headers()
ROUTES = { ROUTES = {
"/cookies/entrance.php": route_entrance, "/cookies/entrance.php": route_entrance,
"/cookies/second.php": route_second, "/cookies/second.php": route_second,
@@ -195,6 +233,8 @@ class Handler(SimpleHTTPRequestHandler):
"/types/style.css": route_types, "/types/style.css": route_types,
"/types/data.json": route_types, "/types/data.json": route_types,
"/types/gen.php": route_types, "/types/gen.php": route_types,
"/resume/index.html": route_resume_index,
"/resume/blob.txt": route_resume,
} }
# --- dispatch ---------------------------------------------------------- # --- dispatch ----------------------------------------------------------