test: add tcache_fiber_migration standalone LTO reproducer

Exercises the tsd thread-pointer hoisting bug: ucontext fibers do
free/swapcontext/malloc in one frame on a worker pool, so a fiber routinely
resumes on a different OS thread; under whole-program LTO the inlined fastpath
then frees/allocs against the previous thread's tcache and crashes.

It is a standalone program (no test harness, so it can be static-linked without
symbol clashes), calling jemalloc via JEMALLOC_MANGLE and linked statically with
--whole-archive so the allocator inlines next to the swapcontext.  Built only
when -flto is in the build flags -- without LTO the allocator is not inlined and
the bug cannot reproduce, so the test would be a meaningless always-pass -- and
gated on ELF + static + have_ucontext (a configure link test, since musl
declares but does not implement getcontext/makecontext/swapcontext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Azat Khuzhin 2026-06-22 12:47:46 +02:00
parent 9f37c70826
commit c68b7b1cac
3 changed files with 181 additions and 0 deletions

View file

@ -59,6 +59,7 @@ enable_autogen := @enable_autogen@
enable_doc := @enable_doc@
enable_shared := @enable_shared@
enable_static := @enable_static@
have_ucontext := @have_ucontext@
enable_prof := @enable_prof@
enable_zone_allocator := @enable_zone_allocator@
enable_experimental_smallocx := @enable_experimental_smallocx@
@ -345,6 +346,24 @@ ifeq (@enable_experimental_smallocx@, 1)
TESTS_INTEGRATION += \
$(srcroot)test/integration/smallocx.c
endif
# tcache_fiber_migration is a standalone LTO reproducer (issue #2890): the
# dedicated rule below links it against the static archive (--whole-archive) so
# the allocator fastpath inlines next to the swapcontext. It needs the static
# archive, GNU-ld --whole-archive semantics (the ELF gate), and working
# ucontext fibers -- have_ucontext is a configure link test because musl
# declares getcontext/makecontext/swapcontext but does not implement them, so
# the ELF gate alone would break `make check` on Alpine/musl. Built only when
# the build uses LTO (-flto in CFLAGS): without inlining across the swapcontext
# the bug cannot reproduce.
ifeq (elf, $(ABI))
ifeq ($(enable_static), 1)
ifeq (1, $(have_ucontext))
ifneq (,$(findstring -flto,$(CFLAGS)))
TESTS_INTEGRATION += $(srcroot)test/integration/tcache_fiber_migration.c
endif
endif
endif
endif
ifeq (@enable_cxx@, 1)
CPP_SRCS := $(srcroot)src/jemalloc_cpp.cpp
TESTS_INTEGRATION_CPP := $(srcroot)test/integration/cpp/basic.cpp
@ -578,6 +597,14 @@ $(objroot)test/integration/%$(EXE): $(objroot)test/integration/%.$(O) $(C_TESTLI
@mkdir -p $(@D)
$(CC) $(TEST_LD_MODE) $(LDTARGET) $(filter %.$(O),$^) $(call RPATH,$(objroot)lib) $(LJEMALLOC) $(LDFLAGS) $(filter-out -lm,$(filter -lrt -pthread -lstdc++,$(LIBS))) $(LM) $(EXTRA_LDFLAGS)
# tcache_fiber_migration (issue #2890) must inline the allocator next to the
# swapcontext, so link jemalloc statically (--whole-archive), without the test
# harness/shared lib; whole-program LTO (from the build's flags) does the
# inlining. Explicit rule -- overrides the generic integration rule above.
$(objroot)test/integration/tcache_fiber_migration$(EXE): $(objroot)test/integration/tcache_fiber_migration.$(O) $(objroot)lib/$(LIBJEMALLOC).$(A)
@mkdir -p $(@D)
$(CC) $(LDTARGET) $(objroot)test/integration/tcache_fiber_migration.$(O) -Wl,--whole-archive $(objroot)lib/$(LIBJEMALLOC).$(A) -Wl,--no-whole-archive $(LDFLAGS) -pthread $(filter-out -lm,$(LIBS)) $(LM) $(EXTRA_LDFLAGS)
$(objroot)test/integration/cpp/%$(EXE): $(objroot)test/integration/cpp/%.$(O) $(C_TESTLIB_INTEGRATION_OBJS) $(C_UTIL_INTEGRATION_OBJS) $(objroot)lib/$(LIBJEMALLOC).$(IMPORTLIB)
@mkdir -p $(@D)
$(CXX) $(LDTARGET) $(filter %.$(O),$^) $(call RPATH,$(objroot)lib) $(objroot)lib/$(LIBJEMALLOC).$(IMPORTLIB) $(LDFLAGS) $(filter-out -lm,$(LIBS)) -lm $(EXTRA_LDFLAGS)

View file

@ -1217,6 +1217,25 @@ if test "$enable_shared$enable_static" = "00" ; then
AC_MSG_ERROR([Please enable one of shared or static builds])
fi
dnl Whether ucontext fibers (getcontext/makecontext/swapcontext) actually link.
dnl musl declares them in <ucontext.h> but exports no implementation, so a
dnl link test is required -- a header check would pass and then fail to link.
dnl Used to gate the tcache_fiber_migration test.
JE_COMPILABLE([ucontext], [
#include <ucontext.h>
], [
ucontext_t uc, ret;
getcontext(&uc);
makecontext(&uc, (void (*)(void))0, 0);
swapcontext(&ret, &uc);
], [je_cv_ucontext])
if test "x${je_cv_ucontext}" = "xyes" ; then
have_ucontext="1"
else
have_ucontext="0"
fi
AC_SUBST([have_ucontext])
dnl Perform no name mangling by default.
AC_ARG_WITH([mangling],
[AS_HELP_STRING([--with-mangling=<map>], [Mangle symbols in <map>])],

View file

@ -0,0 +1,135 @@
/*
* Regression test for issue #2890: under whole-program LTO the inlined allocator
* fastpath caches the TLS-derived tcache base in a register across a
* swapcontext, so a fiber resumed on another OS thread uses the previous
* thread's tcache -> heap corruption. See JEMALLOC_TLS_ADDR in tsd_internals.h.
*
* Standalone (no test harness, so it can be static-linked), calling jemalloc via
* JEMALLOC_MANGLE -- it rewrites malloc/free to jemalloc's configured-prefix
* symbols, so the calls bind to and inline the static allocator, not libc's
* wrappers. Reproduces only with the allocator inlined next to the swapcontext
* (a static --whole-archive link plus whole-program LTO), so the Makefile builds
* it only when -flto is in the build flags.
*/
#include <pthread.h>
#include <stdbool.h>
#include <ucontext.h>
#define JEMALLOC_MANGLE
#include <jemalloc/jemalloc.h>
enum {
num_fibers = 128,
num_workers = 8,
ops_per_fiber = 20 * 1000,
fiber_stack_size = 1 << 16,
ready_queue_capacity = 512 /* power of two, > num_fibers */
};
static ucontext_t fiber_context[num_fibers];
/* Scheduler context to switch back to when a fiber yields; the resuming worker
* writes its own context here, so this changes when the fiber migrates. */
static ucontext_t *return_context[num_fibers];
static unsigned fiber_remaining_ops[num_fibers];
static bool fiber_done[num_fibers];
static unsigned ready_queue[ready_queue_capacity];
static unsigned ready_queue_head;
static unsigned ready_queue_tail;
static unsigned live_fibers;
static pthread_mutex_t queue_mtx = PTHREAD_MUTEX_INITIALIZER;
static void
push_ready_fiber(unsigned id) {
pthread_mutex_lock(&queue_mtx);
ready_queue[ready_queue_head++ & (ready_queue_capacity - 1)] = id;
pthread_mutex_unlock(&queue_mtx);
}
/* Returns a ready fiber id, -1 if none ready now, or -2 if all have finished. */
static int
pop_ready_fiber(void) {
int id;
pthread_mutex_lock(&queue_mtx);
if (live_fibers == 0) {
id = -2;
} else if (ready_queue_tail != ready_queue_head) {
id = (int)ready_queue[ready_queue_tail++ & (ready_queue_capacity - 1)];
} else {
id = -1;
}
pthread_mutex_unlock(&queue_mtx);
return id;
}
static void
fiber_run(int id) {
void *p = malloc(64);
while (fiber_remaining_ops[id] > 0) {
fiber_remaining_ops[id]--;
free(p); /* push to the CURRENT thread's tcache */
/* Yield; may be resumed on a different worker thread. */
swapcontext(&fiber_context[id], return_context[id]);
p = malloc(64); /* pop; must come from the NEW thread's tcache */
*(char *)p = (char)id;
}
free(p);
pthread_mutex_lock(&queue_mtx);
fiber_done[id] = true;
live_fibers--;
pthread_mutex_unlock(&queue_mtx);
swapcontext(&fiber_context[id], return_context[id]);
}
static void *
worker_thread(void *arg) {
ucontext_t scheduler_context;
(void)arg;
for (;;) {
int id = pop_ready_fiber();
if (id == -2) {
break;
}
if (id < 0) {
continue;
}
return_context[id] = &scheduler_context;
swapcontext(&scheduler_context, &fiber_context[id]);
if (!fiber_done[id]) {
push_ready_fiber((unsigned)id);
}
}
return NULL;
}
int
main(void) {
void *stacks[num_fibers];
pthread_t threads[num_workers];
unsigned i;
live_fibers = num_fibers;
for (i = 0; i < num_fibers; i++) {
stacks[i] = malloc(fiber_stack_size);
fiber_remaining_ops[i] = ops_per_fiber;
getcontext(&fiber_context[i]);
fiber_context[i].uc_stack.ss_sp = stacks[i];
fiber_context[i].uc_stack.ss_size = fiber_stack_size;
fiber_context[i].uc_link = NULL;
makecontext(&fiber_context[i], (void (*)(void))fiber_run, 1, (int)i);
push_ready_fiber(i);
}
for (i = 0; i < num_workers; i++) {
pthread_create(&threads[i], NULL, worker_thread, NULL);
}
for (i = 0; i < num_workers; i++) {
pthread_join(threads[i], NULL);
}
for (i = 0; i < num_fibers; i++) {
free(stacks[i]);
}
/* Completing without a tcache-corruption crash is success. */
return live_fibers == 0 ? 0 : 1;
}