A bare __thread at block scope compiles to a garbage stack local
Measured
HEAD, x86-64, 2026-09-19, gcc as the oracle on the same source:
int bump(void) { __thread int t; t++; return t; } /* no `static` */
| compiler | result |
|---|---|
| gcc | refuses: error: function-scope 't' implicitly auto and declared '__thread' |
| pxx | compiles, warns since 09de09465, and still prints a garbage value (586162841, then 1551595657 on a later build — it is indeterminate, so the digits are not the finding) |
THE "NO DIAGNOSTIC" CLAIM WAS BORN FALSE — IT WAS NEVER TRUE FOR ANY READER,
AND THAT IS A DIFFERENT DEFECT FROM A STALE SUMMARY. My first account of this
said the sentence went stale four hours after I wrote it. Checked rather than
recalled, and it is worse than that: git log --diff-filter=A puts this file's
creation in 09de09465 — the same commit as the warning that falsifies
it. In that very tree localSawThread := True is set whether or not static
is present, and the warn fires before both declaration arms, so a bare
__thread warned at the exact revision that introduced the sentence saying it
does not.
The remedy is not the one a stale summary needs. "Re-verify the summary
before you start" does nothing here — there was no interval in which re-reading
would have helped, and the seat that files a ticket alongside a fix is the least
likely to re-read it. What catches this shape is: derive the summary from the
tree you are COMMITTING TO, not from the measurement you took before the fix.
The measurement (586162841, no diagnostic) was true when taken and false by
the time it was written down, and nothing in between announced the change.
That is CLAUDE.md's born red shape — a guard that could never have passed once — arriving in a summary rather than an assertion, where nothing runs it and so nothing reports it. An assertion written from a report of the code fails loudly on arrival; a summary written from a superseded measurement just sits at the top of a queue being read.
The remainder is narrower and still real. A warning is not a refusal, and the acceptance criterion here is the refusal: gcc rejects the program outright, and we build it and hand back a garbage value.
Its sibling — the same declaration with static — prints 1 2 3 under both
compilers and is correct single-threaded.
The cause is one line, and it is the same line as the sibling's
cparser.inc, the block-scope storage-class loop in ParseCStatementAST. It
consumes register|auto|static|__thread|_Thread_local and records only
localSawStatic. So:
- with
static— storage is right, and the thread-local request is lost. That was silent until 2026-09-19 and now warns (TLSREFUSE_FUNCSCOPE). - without
static—localSawStaticstays false, the declaration falls through to the ordinary-local path, and the program gets an uninitialised automatic. This row.
That is the "one seam, two spellings" shape, and finding the second spelling is what makes the first fix trustworthy rather than a patch on the case somebody happened to try.
Why it is filed separately and ranked above its sibling
Refusing this is a behaviour change on something that compiles today, which the
comment above that very loop already settled the discipline for — extern was
deliberately left out of the set because "adding it is a behaviour change this
fix has no measurement for". The measurement now exists, so this earns a
ticket rather than a bolt-on.
It outranks the sibling because of what the two actually cost. The sibling is a program that works single-threaded and is wrong only once threads exist. This one is a wrong value on one thread, today, with nothing to notice it by — and it is a program the oracle rejects outright, so no correct source can depend on the current behaviour.
What to decide
Error like gcc, or warn? This is not the warn-versus-error question the
file-scope path answered and must not be settled by citing it. There, four of
the five refusals are OUR limits and refusing would delete __thread from
programs that are correct today. Here there is no such population: C
requires a block-scope thread-local to be static or extern, so every program
reaching this row is already wrong and gcc already refuses it.
An Error is the honest answer and the argument against it is weak — but it is still a refusal of something that builds today, so whoever takes it should measure the in-tree and busybox populations first rather than assume zero. That count went stale in eight days once already in this subsystem, in the flattering direction.
Positive control
A fixture asserting the refusal must also assert that static __thread int t;
in a body still compiles, or it passes on a change that refuses the whole
block-scope family — which is the sibling's working case and the exact
whole-family shape that has certified a broken half here before.
Log
- 2026-09-19 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 1e1e3f659.