Repository: xroche/httrack (2026-07-30T15:24:27Z)

Issue:
armhf crash reports have no frames, and the empty backtrace fails the build
The Debian 3.49.15-1 armhf build failed the test suite ([buildd log](https://buildd.debian.org/status/fetch.php?pkg=httrack&arch=armhf&ver=3.49.15-1&stamp=1785404564&raw=0)), leaving armhf the only architecture stuck at Build-Attempted:

```
FAIL: 80_engine-crash-symbolize.test
FAIL: 140_crash-handler.test
# TOTAL: 212   # PASS: 106   # FAIL: 2
```

Both are the same thing: on armhf the crash report carries no frames at all.

```
Caught signal 6

Please report the problem at http://forum.httrack.com
```

`backtrace()` returns 0 there because gcc on armhf does not emit unwind tables by default, and the ARM unwinder has nothing to walk. The published binaries show it: `libhttrack.so.3` armhf has 503 KB of `.text` and exactly 8 bytes of `.ARM.exidx` — one entry. For comparison, `libstdc++.so.6` armhf carries 21 KB of `.ARM.exidx` for 742 KB of text.

Two problems, then. The build has no unwind tables on armhf, so a real crash report from an armhf user is useless; and `hts_print_backtrace()` prints nothing when the frame count is zero, so the report goes silent between "Caught signal" and the closing line, which reads as a handler that died halfway — and the tests, having nothing to match, fail the build.



Discussion:

Patch:
diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
index 1c3bb610..35318a59 100644
--- a/.github/workflows/windows-build.yml
+++ b/.github/workflows/windows-build.yml
@@ -269,11 +269,13 @@ jobs:
           # badmtime needs a filesystem that stores an mtime past gmtime's range;
           # single-file ends on a GUI half needing htsserver, which this job does not build;
           # update-304-leak needs a LeakSanitizer build, which MSVC has no equivalent of;
-          # crash-symbolize needs backtrace(), which Windows has no equivalent of.
+          # crash-symbolize and backtrace-empty need backtrace(), which Windows has no
+          # equivalent of.
           expected_skips="01_engine-footer-overflow.test
           100_local-purge-longpath.test
           114_local-update-304-leak.test
           120_local-proxytrack-webdav-default.test
+          143_engine-backtrace-empty.test
           48_local-crange-memresume.test
           71_local-crange-repaircache.test
           79_local-proxytrack-webdav-mime.test
diff --git a/configure.ac b/configure.ac
index 458fbe85..52ad32de 100644
--- a/configure.ac
+++ b/configure.ac
@@ -100,6 +100,9 @@ AX_CHECK_COMPILE_FLAG([-fstack-protector-strong], [DEFAULT_CFLAGS="$DEFAULT_CFLA
 	[AX_CHECK_COMPILE_FLAG([-fstack-protector], [DEFAULT_CFLAGS="$DEFAULT_CFLAGS -fstack-protector"], [], [-Werror])], [-Werror])
 AX_CHECK_COMPILE_FLAG([-fstack-clash-protection], [DEFAULT_CFLAGS="$DEFAULT_CFLAGS -fstack-clash-protection"], [], [-Werror])
 AX_CHECK_COMPILE_FLAG([-fcf-protection], [DEFAULT_CFLAGS="$DEFAULT_CFLAGS -fcf-protection"], [], [-Werror])
+# backtrace() unwinds through these; armhf, unlike amd64/arm64, defaults them off
+# and printed frameless crash reports.
+AX_CHECK_COMPILE_FLAG([-fasynchronous-unwind-tables], [DEFAULT_CFLAGS="$DEFAULT_CFLAGS -fasynchronous-unwind-tables"], [], [-Werror])
 # No --discard-all: it drops the local symbols naming every static function, so
 # a trace misattributes them to the nearest surviving global. Costs 0.6% size.
 AX_CHECK_LINK_FLAG([-Wl,--no-undefined], [DEFAULT_LDFLAGS="$DEFAULT_LDFLAGS -Wl,--no-undefined"])
diff --git a/src/htsbacktrace.c b/src/htsbacktrace.c
index 5ff37afd..a8566358 100644
--- a/src/htsbacktrace.c
+++ b/src/htsbacktrace.c
@@ -211,6 +211,13 @@ void hts_backtrace_init(void) {
 #endif
 }
 
+/* Why the report has no frames: a silent gap reads as a handler that died. */
+static void print_no_trace(int fd, const char *msg, size_t len) {
+  if (write(fd, msg, len) != len) { /* no ssize_t: this is built on MSVC too */
+    /* sorry GCC */
+  }
+}
+
 void hts_print_backtrace(int fd) {
 #ifdef USES_BACKTRACE
   void *stack[256];
@@ -227,12 +234,15 @@ void hts_print_backtrace(int fd) {
       symbolize_backtrace(stack, size, fd);
       entered = 0;
     }
+  } else {
+    /* An empty trace means the build carries no unwind tables. */
+    const char msg[] = "No stack trace available: unwinding failed\n";
+
+    print_no_trace(fd, msg, sizeof(msg) - 1);
   }
 #else
   const char msg[] = "No stack trace available on this OS :(\n";
 
-  if (write(fd, msg, sizeof(msg) - 1) != sizeof(msg) - 1) {
-    /* sorry GCC */
-  }
+  print_no_trace(fd, msg, sizeof(msg) - 1);
 #endif
 }


Test patch:
diff --git a/tests/143_engine-backtrace-empty.test b/tests/143_engine-backtrace-empty.test
new file mode 100644
index 00000000..89a20ff1
--- /dev/null
+++ b/tests/143_engine-backtrace-empty.test
@@ -0,0 +1,70 @@
+#!/bin/bash
+# An empty backtrace() must still leave a report; a silent one reads as a
+# handler that died halfway.
+
+set -euo pipefail
+
+ulimit -c 0 # a deliberate crash must not litter the box with cores
+
+if [ "$(uname -s)" != "Linux" ]; then
+    echo "LD_PRELOAD interposition is Linux-only here, skipping" >&2
+    exit 77
+fi
+command -v httrack >/dev/null || {
+    echo "could not find httrack" >&2
+    exit 1
+}
+# A --disable-shared build has nothing to preload; anything else missing is a
+# build problem, not a skip, or the leg below would pass vacuously.
+if [ ! -r "${NOBACKTRACE_LA:-}" ]; then
+    echo "${NOBACKTRACE_LA:-\$NOBACKTRACE_LA} was not built" >&2
+    exit 1
+fi
+if grep -q "^dlname=''" "$NOBACKTRACE_LA"; then
+    echo "static-only build, skipping" >&2
+    exit 77
+fi
+if [ ! -r "${NOBACKTRACE_LIB:-}" ]; then
+    echo "${NOBACKTRACE_LIB:-\$NOBACKTRACE_LIB} was not built" >&2
+    exit 1
+fi
+
+# An LD_PRELOAD library loads ahead of the executable's own libasan, which ASan
+# refuses by default. The shim allocates nothing, so the ordering is harmless.
+export ASAN_OPTIONS="${ASAN_OPTIONS:+$ASAN_OPTIONS:}verify_asan_link_order=0"
+
+# Control: a build that never traces would pass the assertion below for the
+# wrong reason.
+rc=0
+plain=$(HTTRACK_NO_SYMBOLIZE=1 httrack -#c=abort 2>&1) || rc=$? # no addr2line to wedge on
+test "$rc" -eq 134 || {
+    echo "the plain crash exited $rc, not 134" >&2
+    exit 1
+}
+if grep -q 'No stack trace available' <<<"$plain"; then
+    echo "no working backtrace() in this build; skipping" >&2
+    exit 77
+fi
+
+rc=0
+out=$(LD_PRELOAD="$NOBACKTRACE_LIB" httrack -#c=abort 2>&1) || rc=$?
+test "$rc" -eq 134 || {
+    echo "the interposed crash exited $rc, not 134" >&2
+    printf '%s\n' "$out" >&2
+    exit 1
+}
+grep -q "^Caught signal 6$" <<<"$out" || {
+    echo "no 'Caught signal' line:" >&2
+    printf '%s\n' "$out" >&2
+    exit 1
+}
+grep -q 'No stack trace available: unwinding failed' <<<"$out" || {
+    echo "an empty backtrace left no diagnostic:" >&2
+    printf '%s\n' "$out" >&2
+    exit 1
+}
+grep -q "Please report the problem" <<<"$out" || {
+    echo "the handler stopped before its closing line:" >&2
+    printf '%s\n' "$out" >&2
+    exit 1
+}
diff --git a/tests/80_engine-crash-symbolize.test b/tests/80_engine-crash-symbolize.test
index 6eaed61b..77234147 100644
--- a/tests/80_engine-crash-symbolize.test
+++ b/tests/80_engine-crash-symbolize.test
@@ -43,6 +43,16 @@ if grep -q 'No stack trace available on this OS' "$out"; then
     echo "no backtrace() on this platform; skipping" >&2
     exit 77
 fi
+# 32-bit ARM is the one target whose toolchain may still refuse to unwind;
+# anywhere else an empty trace is the regression this test exists to catch.
+case "$(uname -m)" in
+arm | armv*)
+    if grep -q 'No stack trace available' "$out"; then
+        echo "no unwind tables in this ARM build; skipping" >&2
+        exit 77
+    fi
+    ;;
+esac
 # Unconditional and first: symbolizing must never cost the raw trace.
 grep -q "$rawframe" "$out" || {
     echo "raw module+offset frames are gone:" >&2
diff --git a/tests/Makefile.am b/tests/Makefile.am
index d8eed202..07f89a25 100644
--- a/tests/Makefile.am
+++ b/tests/Makefile.am
@@ -4,7 +4,7 @@ include $(srcdir)/tests-list.mk
 # Committed binary fixture read by 01_zlib-cache-golden.test. List it
 # explicitly: automake does not expand wildcards in EXTRA_DIST, so a glob would
 # silently drop it from the dist tarball and break "make distcheck".
-EXTRA_DIST = $(TESTS) renamefail.c threadattrfail.c crawl-test.sh run-all-tests.sh check-network.sh \
+EXTRA_DIST = $(TESTS) renamefail.c threadattrfail.c nobacktrace.c crawl-test.sh run-all-tests.sh check-network.sh \
 	proxy-https-server.py socks5-server.py proxy-connect-server.py \
 	proxytestlib.py tls-stall-server.py warc-validate.py wacz-validate.py \
 	pty-resize.py test-timeout.sh \
@@ -29,10 +29,12 @@ TESTS_ENVIRONMENT += RENAMEFAIL_LA=$(abs_builddir)/librenamefail.la
 TESTS_ENVIRONMENT += RENAMEFAIL_LIB=$(abs_builddir)/$(LT_CV_OBJDIR)/librenamefail.so
 TESTS_ENVIRONMENT += THREADATTRFAIL_LA=$(abs_builddir)/libthreadattrfail.la
 TESTS_ENVIRONMENT += THREADATTRFAIL_LIB=$(abs_builddir)/$(LT_CV_OBJDIR)/libthreadattrfail.so
+TESTS_ENVIRONMENT += NOBACKTRACE_LA=$(abs_builddir)/libnobacktrace.la
+TESTS_ENVIRONMENT += NOBACKTRACE_LIB=$(abs_builddir)/$(LT_CV_OBJDIR)/libnobacktrace.so
 
 # rename() interposer for 01_engine-renameover.test; -rpath is what makes
 # libtool build a shared module rather than a static-only convenience library.
-check_LTLIBRARIES = librenamefail.la libthreadattrfail.la
+check_LTLIBRARIES = librenamefail.la libthreadattrfail.la libnobacktrace.la
 librenamefail_la_SOURCES = renamefail.c
 librenamefail_la_LDFLAGS = -module -avoid-version -rpath $(abs_builddir)
 librenamefail_la_LIBADD = $(DL_LIBS)
@@ -42,6 +44,10 @@ libthreadattrfail_la_SOURCES = threadattrfail.c
 libthreadattrfail_la_LDFLAGS = -module -avoid-version -rpath $(abs_builddir)
 libthreadattrfail_la_LIBADD = $(DL_LIBS)
 
+# backtrace() interposer for 143_engine-backtrace-empty.test
+libnobacktrace_la_SOURCES = nobacktrace.c
+libnobacktrace_la_LDFLAGS = -module -avoid-version -rpath $(abs_builddir)
+
 TEST_EXTENSIONS = .test
 # Run each .test through bash instead of execve()ing it. This lets "make check"
 # work when the source tree sits on a noexec filesystem (the driver would
diff --git a/tests/nobacktrace.c b/tests/nobacktrace.c
new file mode 100644
index 00000000..26aa8447
--- /dev/null
+++ b/tests/nobacktrace.c
@@ -0,0 +1,15 @@
+/* Forces the empty backtrace() of an unwind-tableless build, for
+   143_engine-backtrace-empty.test. */
+
+#define _GNU_SOURCE
+
+/* The tree builds with -fvisibility=hidden, which would hide the interposer. */
+#define SHIM_EXPORT __attribute__((visibility("default")))
+
+SHIM_EXPORT int backtrace(void **buffer, int size);
+
+SHIM_EXPORT int backtrace(void **buffer, int size) {
+  (void) buffer;
+  (void) size;
+  return 0;
+}
diff --git a/tests/tests-list.mk b/tests/tests-list.mk
index 10228fad..888c6414 100644
--- a/tests/tests-list.mk
+++ b/tests/tests-list.mk
@@ -214,3 +214,4 @@ TESTS += 139_local-query-charref.test
 TESTS += 140_crash-handler.test
 TESTS += 141_webhttrack-warc-options.test
 TESTS += 142_webhttrack-content-type.test
+TESTS += 143_engine-backtrace-empty.test