Repository: xroche/httrack (2026-07-27T11:50:04Z)

Issue:
hts_newthread() leaves the pthread attributes object undestroyed when thread creation fails
`hts_newthread()` (src/htsthread.c) initialises a `pthread_attr_t`, sets a stack size on it, creates the thread, and destroys the attributes only on the success path:

```c
    if (pthread_attr_init(&attr) != 0
        || pthread_attr_setstacksize(&attr, stackSize) != 0
        || (retcode =
            pthread_create(&handle, &attr, hts_entry_point, s_args)) != 0) {
      process_chain_add(-1);
      freet(s_args);
      return -1;
    } else {
      pthread_detach(handle);
      pthread_attr_destroy(&attr);
    }
```

If `pthread_attr_init()` succeeded and either `pthread_attr_setstacksize()` or `pthread_create()` then failed, `attr` is left initialised and never destroyed. `pthread_create()` failing is the realistic case, under `EAGAIN` from thread or memory exhaustion.

Low severity, and not a one-line fix. POSIX lets an implementation allocate for an attributes object, but glibc's does not for a default-initialised one, so on Linux the destroy is close to a no-op and nothing actually leaks. It is a portability and hygiene problem rather than a live bug.

The reason it needs restructuring rather than an added `pthread_attr_destroy()`: the three calls share one short-circuit condition, so the failure branch cannot tell which one failed, and destroying an attributes object that `pthread_attr_init()` never initialised is itself undefined. The init has to be split out of the chain first.

Found while reviewing #752.



Discussion:

Patch:
diff --git a/src/htsthread.c b/src/htsthread.c
index dc0e8180..8435490e 100644
--- a/src/htsthread.c
+++ b/src/htsthread.c
@@ -148,20 +148,23 @@ HTSEXT_API int hts_newthread(void (*fun) (void *arg), void *arg) {
     const size_t stackSize = 1024 * 1024 * 8;
     pthread_attr_t attr;
     pthread_t handle = 0;
-    int retcode;
+    hts_boolean created;
 
-    if (pthread_attr_init(&attr) != 0
-        || pthread_attr_setstacksize(&attr, stackSize) != 0
-        || (retcode =
-            pthread_create(&handle, &attr, hts_entry_point, s_args)) != 0) {
+    /* init kept apart: destroying an uninitialised attr is undefined (#772) */
+    if (pthread_attr_init(&attr) == 0) {
+      created = pthread_attr_setstacksize(&attr, stackSize) == 0 &&
+                pthread_create(&handle, &attr, hts_entry_point, s_args) == 0;
+      pthread_attr_destroy(&attr); /* create() copied what it needed */
+    } else {
+      created = HTS_FALSE;
+    }
+    if (!created) {
       process_chain_add(-1);
       freet(s_args);
       return -1;
-    } else {
-      /* detach the thread from the main process so that is can be independent */
-      pthread_detach(handle);
-      pthread_attr_destroy(&attr);
     }
+    /* detach the thread from the main process so that it can be independent */
+    pthread_detach(handle);
   }
 #endif
   return 0;


Test patch:
diff --git a/tests/113_engine-threadattr-leak.test b/tests/113_engine-threadattr-leak.test
new file mode 100644
index 00000000..96bc7b06
--- /dev/null
+++ b/tests/113_engine-threadattr-leak.test
@@ -0,0 +1,62 @@
+#!/bin/bash
+#
+# Issue #772: hts_newthread() destroyed the pthread attributes object only when
+# the thread started, so a failed pthread_create() left it initialised forever.
+# glibc allocates nothing for a default attr, so no leak checker sees it: the
+# interposer counts the init/destroy imbalance instead.
+
+set -euo pipefail
+
+if [ "$(uname -s)" != "Linux" ]; then
+    echo "threadattr: LD_PRELOAD interposition is Linux-only here, skipping"
+    exit 0
+fi
+# A --disable-shared build has nothing to preload; anything else missing is a
+# build problem, not a skip, or the leg would pass vacuously.
+if [ ! -r "${THREADATTRFAIL_LA:-}" ]; then
+    echo "threadattr: ${THREADATTRFAIL_LA:-\$THREADATTRFAIL_LA} was not built" >&2
+    exit 1
+fi
+if grep -q "^dlname=''" "$THREADATTRFAIL_LA"; then
+    echo "threadattr: static-only build, skipping"
+    exit 0
+fi
+if [ ! -r "${THREADATTRFAIL_LIB:-}" ]; then
+    echo "threadattr: ${THREADATTRFAIL_LIB:-\$THREADATTRFAIL_LIB} was not built" >&2
+    exit 1
+fi
+
+dir=$(mktemp -d)
+trap 'set +e; rm -rf "$dir"' EXIT
+trap 'rm -rf "$dir"' HUP INT QUIT PIPE TERM
+
+# 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"
+
+# The self-test spawns threads and gives up on the first refusal; it is the
+# driver here, so its failure is expected and not the verdict.
+log="$dir/counts"
+THREADATTRFAIL=1 THREADATTRFAIL_LOG="$log" LD_PRELOAD="$THREADATTRFAIL_LIB" \
+    httrack -O "$dir/o" -#test=threadwait >"$dir/out" 2>&1 || true
+
+test -r "$log" || {
+    echo "threadattr: the interposer wrote no counts" >&2
+    cat "$dir/out" >&2
+    exit 1
+}
+sabotaged=$(sed -n 's/^sabotaged //p' "$log")
+undestroyed=$(sed -n 's/^undestroyed //p' "$log")
+
+# Teeth: with no refused create there is nothing to leak, and the check below
+# would pass on any build.
+test "${sabotaged:-0}" -ge 1 || {
+    echo "threadattr: no pthread_create() was refused, nothing was tested" >&2
+    cat "$dir/out" >&2
+    exit 1
+}
+test "${undestroyed:-1}" -eq 0 || {
+    echo "threadattr: $undestroyed of $sabotaged attributes objects survived a refused pthread_create()" >&2
+    exit 1
+}
+echo "OK: a refused pthread_create() destroys the attributes object ($sabotaged refused)"
diff --git a/tests/Makefile.am b/tests/Makefile.am
index 89731e63..d8eed202 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 crawl-test.sh run-all-tests.sh check-network.sh \
+EXTRA_DIST = $(TESTS) renamefail.c threadattrfail.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 \
@@ -27,14 +27,21 @@ TESTS_ENVIRONMENT += V6_SUPPORT=$(V6_SUPPORT)
 TESTS_ENVIRONMENT += top_srcdir=$(top_srcdir)
 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
 
 # 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
+check_LTLIBRARIES = librenamefail.la libthreadattrfail.la
 librenamefail_la_SOURCES = renamefail.c
 librenamefail_la_LDFLAGS = -module -avoid-version -rpath $(abs_builddir)
 librenamefail_la_LIBADD = $(DL_LIBS)
 
+# pthread_create() interposer for 113_engine-threadattr-leak.test
+libthreadattrfail_la_SOURCES = threadattrfail.c
+libthreadattrfail_la_LDFLAGS = -module -avoid-version -rpath $(abs_builddir)
+libthreadattrfail_la_LIBADD = $(DL_LIBS)
+
 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/tests-list.mk b/tests/tests-list.mk
index 134d47f7..684bf0ff 100644
--- a/tests/tests-list.mk
+++ b/tests/tests-list.mk
@@ -95,6 +95,7 @@ TESTS += 106_engine-repair-rename.test
 TESTS += 10_crawl-simple.test
 TESTS += 110_local-ftp-parallel.test
 TESTS += 111_local-ftp-update-rest.test
+TESTS += 113_engine-threadattr-leak.test
 TESTS += 112_local-single-file-fragment.test
 TESTS += 114_local-update-304-leak.test
 TESTS += 117_local-proxytrack-ndx-cursor.test
diff --git a/tests/threadattrfail.c b/tests/threadattrfail.c
new file mode 100644
index 00000000..03442726
--- /dev/null
+++ b/tests/threadattrfail.c
@@ -0,0 +1,83 @@
+/* Forces pthread_create() to fail for 113_engine-threadattr-leak.test, and
+   tracks which attributes objects were left undestroyed after that failure
+   (#772). glibc allocates nothing for a default attr, so the imbalance is
+   invisible to a leak checker and has to be counted here. */
+
+#define _GNU_SOURCE
+#include <dlfcn.h>
+#include <errno.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* The tree builds with -fvisibility=hidden, which would hide the interposer. */
+#define SHIM_EXPORT __attribute__((visibility("default")))
+
+/* Every create is refused in the only mode this shim runs in, so the process
+   stays single-threaded: unlocked counters and a fixed 64 slots are enough. */
+#define PENDING_MAX 64
+
+static const void *pending[PENDING_MAX];
+static unsigned int pending_n;
+static unsigned int sabotaged;
+
+SHIM_EXPORT int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
+                               void *(*start)(void *), void *arg);
+SHIM_EXPORT int pthread_attr_destroy(pthread_attr_t *attr);
+
+SHIM_EXPORT int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
+                               void *(*start)(void *), void *arg) {
+  static int (*real_create)(pthread_t *, const pthread_attr_t *,
+                            void *(*) (void *), void *) = NULL;
+
+  if (getenv("THREADATTRFAIL") != NULL) {
+    /* only an attr-bearing spawn can leak one, so only those count as tested */
+    if (attr != NULL) {
+      sabotaged++;
+      if (pending_n < PENDING_MAX)
+        pending[pending_n++] = attr;
+    }
+    return EAGAIN;
+  }
+  if (real_create == NULL) {
+    *(void **) &real_create = dlsym(RTLD_NEXT, "pthread_create");
+    if (real_create == NULL)
+      return ENOSYS;
+  }
+  return real_create(thread, attr, start, arg);
+}
+
+SHIM_EXPORT int pthread_attr_destroy(pthread_attr_t *attr) {
+  static int (*real_destroy)(pthread_attr_t *) = NULL;
+  unsigned int i;
+
+  /* drop one entry, so an address reused by a later init still counts once */
+  for (i = pending_n; i > 0; i--) {
+    if (pending[i - 1] == attr) {
+      memmove(&pending[i - 1], &pending[i],
+              (pending_n - i) * sizeof(pending[0]));
+      pending_n--;
+      break;
+    }
+  }
+  if (real_destroy == NULL) {
+    *(void **) &real_destroy = dlsym(RTLD_NEXT, "pthread_attr_destroy");
+    if (real_destroy == NULL)
+      return ENOSYS;
+  }
+  return real_destroy(attr);
+}
+
+__attribute__((destructor)) static void threadattrfail_report(void) {
+  const char *const path = getenv("THREADATTRFAIL_LOG");
+  FILE *fp;
+
+  if (path == NULL)
+    return;
+  fp = fopen(path, "wb");
+  if (fp == NULL)
+    return;
+  fprintf(fp, "sabotaged %u\nundestroyed %u\n", sabotaged, pending_n);
+  fclose(fp);
+}