Files
codebase-memory-mcp/tests/test_thread_stack_tls_contract.sh
Martin Vogel 92e7e6a913 fix: keep large thread-locals off the static TLS block
Indexing broke on x86-64 Linux: the index worker refused to start and
SIGKILLed its own process group, so every venue reported nothing but
"index worker ended with killed (exit=-1, signal=9)".

glibc places a thread's static TLS block inside the stack allocation it
makes for that thread. This branch's new thread-local caches -- a
512-slot field cache (~24 KB), per-depth tree cursors (~4 KB) and two
parked CBMArenas (~8 KB; an arena carries blocks[256] + block_sizes[256])
-- took static TLS from 47,905 to 93,513 bytes. The 64 KB parent-death
watchdog thread then could not be created at all, and a worker without
process-tree containment correctly refuses to index.

Four layers, because there were four things wrong:

  - The big thread-locals move to the heap behind a thread-local POINTER,
    allocated once per thread and reused, each with a correct fallback if
    allocation fails (the field cache and cursor pool are caches; a miss
    is slower, never wrong). Static TLS is back to 52,233 bytes.
  - PARENT_WATCHDOG_STACK_SIZE 64 KB -> 256 KB. "The watchdog only polls,
    a tiny stack suffices" was true when written and false once TLS
    reached ~50 KB, because the TLS block comes out of that same
    allocation.
  - cbm_thread_create retries with the default stack on EINVAL. A stack
    size is a hint about what a thread needs; the platform refusing the
    hint is not a reason to fail to create the thread.
  - tests/test_thread_stack_tls_contract.sh (Step 5a of scripts/test.sh)
    keeps static TLS a quarter clear of the smallest requested stack,
    reading that smallest size from the sources so a new small stack
    tightens the gate automatically.

Two diagnostics that made this findable at all, and that stay:
smoke-test.sh prints the worker log its failure message names (CI deletes
that sandbox with the job, so the one artifact naming the cause was
unreadable), and worker_containment_unavailable() now reports WHICH of
its conditions failed, with pid/pgrp/ppid.

Verified in the amd64 container, the venue nothing on the local ladder
covers: baseline main indexes 9039 nodes/15067 edges, this branch before
the fix refuses to start the worker, and after it indexes 9039/15067.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-18 23:26:05 +02:00

83 lines
3.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Static TLS must fit inside the SMALLEST stack any thread asks for.
#
# Why this exists: glibc places a thread's static TLS block inside the stack
# allocation it makes for that thread. So growing a thread-local — anywhere in
# the image, in a file that has nothing to do with threads — silently shrinks
# the usable stack of every thread, and once the block no longer fits the
# smallest requested stack, pthread_create returns EINVAL for that thread and
# ONLY that thread.
#
# What that looked like in practice (PR #2233): ~45 KB of new thread-local
# caches (a 512-slot field cache, per-depth tree cursors and two parked arenas —
# a CBMArena is ~4 KB because it carries blocks[256] + block_sizes[256]) pushed
# static TLS from 47.9 KB to 93.5 KB. The 64 KB parent-death watchdog thread
# then could not start, the index worker correctly refused to run without
# process-tree containment and SIGKILLed its own group, and every venue reported
# the same uninformative "index worker ended with killed (signal 9)". Local
# macOS and arm64 Linux legs were fully green throughout: nothing on the ladder
# built x86-64, and the arm64 TLS layout stayed just under the line.
#
# The gate: PT_TLS MemSiz of the built product binary must stay a safe margin
# below the smallest stack size the code requests for a thread. That is an ELF
# property, so the check is Linux-only; the macOS host leg skips with a reason
# (Mach-O has no PT_TLS, and its TLS is not carved from the thread stack).
#
# Usage: tests/test_thread_stack_tls_contract.sh [binary]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BINARY="${1:-${CBM_TEST_BINARY:-}}"
if [ -z "$BINARY" ] || [ ! -f "$BINARY" ]; then
echo "FAIL: no product binary to inspect (pass one, or set CBM_TEST_BINARY)" >&2
exit 1
fi
case "$(uname -s)" in
Linux) ;;
*)
echo "SKIP: static-TLS budget is an ELF/glibc property (Mach-O keeps TLS off" \
"the thread stack) — the Linux legs own this contract"
exit 0
;;
esac
if ! command -v readelf >/dev/null 2>&1; then
echo "SKIP: readelf not available to read PT_TLS"
exit 0
fi
# The smallest stack the code asks for, read from the source rather than
# duplicated here: a future smaller stack must tighten this gate automatically.
smallest_kb=$(grep -ho '[A-Z_]*STACK_SIZE = [0-9]* \* CBM_SZ_1K' "$ROOT"/src/*.c "$ROOT"/src/*/*.c |
sed 's/.* = \([0-9]*\) \* CBM_SZ_1K/\1/' | sort -n | head -1)
if [ -z "$smallest_kb" ]; then
echo "FAIL: could not find any <NAME>_STACK_SIZE in the sources — the gate lost its input" >&2
exit 1
fi
smallest=$((smallest_kb * 1024))
tls_hex=$(readelf -lW "$BINARY" | awk '$1 == "TLS" { print $6 }' | head -1)
if [ -z "$tls_hex" ]; then
echo "SKIP: binary has no PT_TLS segment (nothing to bound)"
exit 0
fi
tls=$((tls_hex))
# The thread descriptor and guard share that allocation too, so the budget is
# not the whole stack. A quarter of it is reserve enough to notice growth long
# before a thread stops being creatable.
budget=$((smallest - smallest / 4))
echo "static TLS ${tls} bytes; smallest requested thread stack ${smallest} bytes; budget ${budget}"
if [ "$tls" -ge "$budget" ]; then
echo "FAIL: static TLS (${tls} B) is within a quarter of the smallest thread stack" \
"(${smallest} B). glibc takes the TLS block out of that stack, so this is how" \
"a thread silently stops being creatable — move large thread-locals to the" \
"heap behind a thread-local POINTER (see cbm.c's field cache/cursor pool)." >&2
exit 1
fi
echo "Thread-stack TLS contract passed"