← board

A symbol a threadvar program never declared is lowered as a threadvar

How it was found

Not by looking for it. Adding one unused integer constant to lib/rtl/palthread.pas flipped test/test_a_threadvar_is_per_thread.pas from 10/10 clean to 10/10 SIGSEGV — same compiler, one line apart, and the pinned compiler reproduced it identically, so it was never the branch under edit.

A crash that moves when you add an unrelated declaration is about symbol numbering, not about the declaration. PXXDBG=a.ir:Body said it outright:

passing:   0: load_sym a=331 ... [sym=arg]
+1 const:  0: tlsbase ... / 1: load_mem a=0

The procedure's own parameter had become a read of the thread block's first word. idx := Integer(PtrUInt(arg)) then indexed an array with a garbage value, which is the only reason this ever crashed.

The defect

ir_codegen.inc, RewriteThreadVarRefs — the whole test:

if (sym >= 0) and (sym < SymCount) and (SymTlsOffset[sym] >= 0) then

-1 means not a threadvar. EnsureSymCapacity grows the parallel arrays with SetLength, which zero-fills — and 0 is a perfectly valid TLS offset to that comparison. Only AllocVar wrote the sentinel; AllocParam, AllocArray, AllocDynArray and AddConst did not.

It stayed invisible for two compounding reasons:

The fix

compiler/symtab.inc: the sentinel in all five Alloc*/AddConst paths, plus for i := SymCapacity to n - 1 do SymTlsOffset[i] := -1; beside the existing SymHashBkt fill. The per-allocator write is the load-bearing half — it is what clears a reused slot; the capacity fill only covers slots nobody reached yet.

Measurements

pin v408 (unfixed) fixed
test_a_threadvar_program_does_not_lower_ordinary_symbols_through_gs params=39363342 — GS LEAK params=78 78 — NO GS LEAK
test_a_threadvar_is_per_thread, palthread + 1 unused const 10/10 SIGSEGV 10/10 THREADVAR OK
same, padding sweep 0..8 unused consts pass, then 8 x SIGSEGV 9 x pass

The new fixture needs uses palthread and a little padding to witness the bug, because the trigger is index arithmetic. Swept against the unfixed compiler over padding 0..24: 0 and 1 pass, 2..24 all leak — a broad plateau, so it keeps witnessing across RTL drift. It is a witness; the guarantee is the sentinel.

Sibling check

SymAllocSize is written in four of the five paths and its zero-fill is inert. Every other field initialised only in AllocVar (SymCLongRank, SymObjDataExternOnly, SymCStaticLink, SymObjDataScope, SymObjRuntimeCopy) wants False/0, which is what the zero-fill gives. SymCModule wants -1 and gets 0, a valid module index — same shape, not reached by any measurement here, and worth its own look by whoever owns the C frontend's linkage.