← board

A block-scope static is dropped when a storage class precedes the type

The defect

cparser.inc, ParseCStatementAST. The old shape:

if (CurTok.Kind = tkIdent) and (CurTok.SVal = 'static') then
begin
  Next;
  if IsCTypeTok then
  begin
    CLocalStaticDecl := True;   { ...only on this path }

static __thread int f; reads static, consumes it, tests IsCTypeTok, meets __thread — and falls out of the branch with CLocalStaticDecl still False and the static already thrown away. The declaration then parses as an ordinary local.

This is not a thread-local bug. Any storage class standing between static and the type does it; __thread is simply the one someone wrote.

Measured 2026-09-16, single-threaded, x86-64, default target

static __thread int f; f++; return f; called three times:

result
gcc -O2 1 2 3
pxx before 4388880 4388881 4388882
pxx before, with = 0 1 1 1
pxx before, plain int f = 0; (suspect control) 1 1 1
pxx before, static int f; (working control) 3 2 1
pxx after 1 2 3

The = 0 row reading identically to a plain stack local is what identified the mechanism. The clincher was calling through a recursion that pushes a 512-byte frame: the counter restarted, which a static cannot do.

Two findings worth more than the fix

A ONE-CALL PROBE CANNOT SEE THIS, AND MINE DIDN'T. The first investigation recorded static __thread int f = 0; as working, because it called the function once and got 0 — which is also what a fresh stack local holds. A discarded static and a real static agree on the first call and diverge only from the second. That is the "if the machinery did nothing at all, would this row still pass?" question, answered yes, in the one shape where the honest answer looks like a pass.

AND THE NO-INITIALISER ROW CAN PASS ON THE BROKEN COMPILER BY LUCK. Standalone, the unfixed compiler returned 4388880… for static __thread int f;. Inside the regression test's larger program the same declaration returned 1 2 3 — the wrongly-chosen stack slot happened to hold 0. Uninitialised stack memory is zero far too often for a no-initialiser row to discriminate. The test keeps that row because it is the shape users write, and rests its verdict on the seeded and deeper-frame rows instead.

The fix

One order-independent loop over the storage classes that may precede a local type, recording whether static was among them.

The set is deliberately narrow and is NOT CIsTopLevelSkipIdent. That set also holds asm, __asm__, _Static_assert and __attribute__, every one of which is a legal statement at block scope — skipping them here would eat an asm(...) statement before anything could parse it. extern is left out on purpose: legal at block scope, not handled today, and adding it is a behaviour change with no measurement behind it.

Verified

test/c_block_static_survives_a_storage_class.c, wired into test-core, six rows, every one called more than once:

pxx     block static survives a storage class: 6 rows OK   rc=0
gcc     block static survives a storage class: 6 rows OK   rc=0
PINNED  FAIL zero / FAIL seed / FAIL deeper / FAIL back    rc=1

__thread static (specifier before static) is not a row: gcc refuses it ("'__thread' before 'static'") and pxx accepts it. Us accepting what gcc rejects is not a defect, but a row gcc cannot compile would cost the file its oracle. The C11 static _Thread_local spelling is a row instead.

Controls re-run and unmoved: block-scope static aggregates (sqlite's static sqlite3_vfs aVfs[] shape — 1 x 2 y same=1, matching gcc) and block-scope inline asm.

Residual, not this ticket

Function-scope __thread now has correct storage and is still one copy shared by every thread — correct single-threaded, wrong under threads, with no diagnostic. That belongs to [[bug-c-thread-local-storage-still-shares-one-copy-off-x86-64-and-a-warning-is-all-that-stands-there]].

Found separately while probing: _Static_assert at block scope is refused (call to undeclared function: _Static_assert), identically on HEAD and on the pin, so pre-existing and unrelated. Filed as [[bug-c-_Static_assert-is-refused-at-block-scope]].

Gate: make compiler/pascal26 converged after 1 round (24cf75e4ff7d); tools/gate.sh quick GREEN.