← board

A thread nobody joins never gives back its 1028 kB stack mapping

THE MECHANISM IN THE FIRST VERSION OF THIS TICKET WAS WRONG AND IT IS CORRECTED IN PLACE BELOW. It said the missing pthread_detach was the cause and that glibc was holding the memory. It is not: pxx programs are statically linked unless something drags in a shared library, PthreadRouteAvailable answers False, and the CLONE route is what actually runs. A routeprobe printed PthreadId=0, and readelf -d on the fixture binary shows no dynamic section at all. The leaked memory is OURS, not glibc's: PalThreadJoin is the only thing in the tree that munmaps h.StackBase, so a thread nobody joins keeps its own stack mapping forever. The whole detach design in "WHAT THE FIX IS NOT" below was built for a route these programs never take.

Measured at the same time: the leak is one mmap of exactly h.StackSize (1024 kB stack + a 4 kB guard page), not two resources. It reads as 2 mappings because PalThreadCreate mprotects the low page PROT_NONE, which splits one VMA into two. 1028 kB per thread accounts for it completely, so there is no second leaked region to look for — the TLS and alt-stack blocks named in the old title are carved off the top of that same mapping on this route.

Measured 2026-09-15, --threadsafe, CPython as oracle:

mappings VSZ
pxx, 100 threads started and joined 10 -> 10 flat
pxx, 100 threads started, never joined 10 -> 209 +1028 kB per thread
CPython, either way 50 -> 50 flat

Exactly 2 mappings per unjoined thread, never returned. RSS grows only ~8.6 kB/thread, which is why every RSS-based leak instrument we have is blind to this: a thread stack is mmap'd and mostly untouched, so it costs ADDRESS SPACE and mapping-table slots rather than resident pages.

THE RESOURCE THAT RUNS OUT IS THE MAPPING TABLE, NOT MEMORY

vm.max_map_count bounds it: every mmap in the process, the heap's own included, starts failing once the table is full, and the failure lands nowhere near the thread code. Not a slope, a cliff.

THE "85 MINUTES TO A CLIFF IN LEKKERZEILEN" IN THE FIRST VERSION OF THIS TICKET WAS WRONG IN BOTH FACTORS AND THE DEMO DOES NOT HIT THIS AT ALL. Recorded here rather than deleted, because the way it was wrong is the reusable part:

c8's direct measurement of the running demo, sampled every 60 s with a positive control proving the counter reads non-zero, settles it:

t_s maps VSZ_MB RSS_MB threads
0 824 2684 179 11
60 859 2770 321 10
120 860 3026 449 10
300 860 3538 962 10
480 860 3794 1218 10

Mappings flat at 860 for six minutes while RSS climbs a full gigabyte — that climb is the arena chain, a different defect. So this bug is real on its own fixture and lekkerzeilen does not accumulate unjoined-thread mappings. It is not a lekkerzeilen blocker and must not be ranked as one. That is also why it is filed against the LANGUAGE: a server or worker-pool program that starts threads faster than it joins them is the population that hits it, and we do not have one to measure.

WHAT THE FIX IS NOT (kept: it is correct about the pthread route, which is

not the route in play, and someone will propose it again)

Do NOT make the thread detach itself at exit and do NOT detach daemon threads at creation. CPython allows t.join() on a daemon thread, so detaching at creation turns a legal daemon_thread.join() into undefined behaviour. (That correction was itself a correction to this ticket, made an hour after filing: the registry's "being killed at exit is what daemon MEANS" is about EXIT, not about whether a caller may join one, and I read the second into the first.)

A PalThreadDetach + detached-first arm in PalThreadJoin WAS written and measured on 2026-09-15. It changed nothing — pre-fix and fixed both leaked 120 maps / 61680 kB per 60 threads — because it targets pthread_detach on a route these binaries never take. It was reverted rather than landed: it is plausible-looking, unexercised on this box, and CLAUDE.md's rule is "Verified, not believed." If the pthread route ever becomes the common one, that design is the right one and this paragraph says where to start.

WHAT LANDED: A REAPER, BECAUSE A THREAD CANNOT FREE ITS OWN STACK

The release has to happen on another thread, later — which is also how glibc does it. lib/rtl/palthread.pas now keeps a small table of clone-route handles and sweeps it at the top of every PalThreadCreate, munmapping any entry whose TidWord the kernel has cleared.

TidWord is the signal and it is the kernel's, not ours: CLONE_CHILD_CLEARTID zeroes it in mm_release() during do_exit, after the thread can no longer touch its user stack. PalThreadJoin already waits on that exact word before its own munmap, and pthread_join relies on the same guarantee to recycle a stack.

Registered at CREATE, not at DETACH, and that is the design decision. An explicit detach needs every caller to predict that it will not join; daemon=True is one such caller, but an ordinary thread that is simply never joined is the same leak with nobody to flag it. Sweeping on the kernel's liveness bit covers both: a joined thread has StackBase = 0 by then and is skipped, and a thread joined LATER finds StackBase = 0, its TidWord already 0, and returns without waiting.

Two guards worth knowing about before touching it:

Bound: a dead thread's stack is held until the next thread starts. The last thread's stack is never reclaimed — one mapping at exit, not a growing leak.

THE FIXTURE, AND WHAT ITS CONTROLS ARE FOR

test/test_nilpy_a_thread_nobody_joins_gives_its_stack_back.npy, wired into the test target with --threadsafe (without it mimic_threading is unreachable and the row would pass by not testing anything).

row pre-fix fixed CPython
daemon, 60 threads, nobody joins +120 maps / +61680 kB +0 / +0 +0 / +0
joined, 40 threads +0 +0 +0
live control, 40 threads still running +80 maps +79 +87
all threads completed 80/80 80/80 80/80

The live row is the positive control and it guards the dangerous direction. Every other row asserts a number stays near zero, so a broken instrument passes all of them. More than that: the way this fix can be WRONG is freeing a stack still being stood on, so live holds 40 threads open across further thread creation — every creation sweeps — and asserts both that the mappings are visible (the instrument moves) and that all 80 threads still finish with a correct count. Both controls pass on the PRE-FIX build too, which is what makes the one red row attributable.

Gated on: the fixture against a real pre-fix control (PXX_HOME at a git archive HEAD tree), 17 named threading fixtures, gate.sh quick GREEN, and a built lekkerzeilen that survived its full 90 s run (in-loop 86.78 s, against the known-good 87.17 s) — because tonight already produced a change that passed all 963 Track N fixtures and killed the demo in 3.0 s.

HOW TO MEASURE IT

Count /proc/self/maps lines and statm field 0 (VSZ), NOT field 1 (RSS). A probe that watches RSS reports a nearly-flat line for a leak that is consuming the mapping table at 2 slots per thread (one mmap, split by the guard page). Probe is at $SCRATCH/thleak.npy; the shape is 50 threads spawned-and-joined, then 50 spawned and not joined, with both counters printed between phases.

The TidWord half of this — a TIMED join burning its whole timeout and never reaping, so pthread_join never ran at all — was fixed in 06e40fb95. That made timed joins reap; it does nothing for a thread nobody joins.

Log