Two same-named file-scope static variables share one Syms row, and alias
The sibling of the function arm
feature-c-two-same-named-file-scope-statics-share-one-procs-row-so-neither-can-have-a-symbol
fixed the FUNCTION arm: a static function now gets a Procs row per (module,
name), and C name resolution prefers the asking module's own body. This is the
same defect one namespace over, and it is worse.
Functions were saved by an accident of the fixup machinery — each call site
keeps a CallFixTarget snapshot and stays BAKED, so calls reached the right
body even while sharing a row. Variables have no such snapshot. One row
means one address means one variable.
Measured
test/fixtures/-style probe, two modules each with static int v initialised
to 1 and 2, plus accessors. Oracle is gcc compiling the three files as three
translation units, which is the semantics pxx's single-buffer module
attribution emulates:
pxx (one buffer) gcc -DSEPARATE_TU (three TUs)
1 2 1 <- module A reads B's value
2 2 2
3 70 70 <- set_a(70)
4 70 2 <- ...changed module B's v
Rows 1 and 4 are the finding. Row 4 is the sharper of the two: a write through
module A's v is observable through module B's v, which is aliasing across
what C guarantees are two distinct objects with internal linkage.
Probe sources are in the scratchpad of the session that found this, not in
test/ — a test belongs with the fix, and adding a red row would land a known
failure in test-core.
Why it is not urgent
Zero instances in crtl. Every file-scope static variable name in
lib/crtl/src/*.c is unique across modules (checked by name across all
modules, 2026-09-05), so no crtl module aliases another's state today. The
reachable population is a user's unity build, or a future crtl module that
reuses a name — and the second is a live hazard precisely because nothing warns.
Note the function arm's diagnostic does not cover this: the duplicate-definition warning is about bodies, and two same-named statics in ONE module warn while two in different modules deliberately do not. A variable collision is silent in both cases.
The shape of the fix, and why it is bigger than the function arm
The function arm needed three call sites changed (CFindProcFromModule at the
call path, the address-decay path and the redeclaration path) because C
function names resolve through FindProc in few places. Variables resolve
through FindSym, which cparser.inc calls 28 times, and it is a hot path.
What is needed:
SymCModule : array of Integerindefs.inc, the data-side twin ofProcCModule,-1for "not a C module". Set it where linkage is already decided —CRecordGlobalLinkageis the single choke point both declaration paths were routed through for exactly this kind of reason, so it is the right place and the two cannot drift.- A rung in the declaration path: a
staticdeclaration whose found symbol is a static defined in another module must not seize that row. MirrorRUNG SinParseCSubroutine, including its deliberate narrowness — both sidesstatic, both in the current unit, the existing row's module known. - A module-preferring lookup for C. The 28
FindSymsites are the cost; the function arm'sCFindProcFromModuleis the model, including its fast-out (a name whose found row already matches the asking module never walks).
Do not change FindSym itself, for the reason CFindProcFromModule
records about FindProc: it serves Pascal and NilPy too, and a C-module
preference has no meaning there.
Check before designing
SymCStaticLink, SymObjDataExternOnly, SymObjDataScope, SymObjRuntimeCopy
and SymAllocSize are all per-row, so today the second module's attributes
silently overwrite the first's — the same side effect the function arm had.
A reader should not assume the current values are right for the first symbol.
2026-09-05 (frankC, Track C) — FIXED
Three parts, following the function arm exactly as this ticket proposed.
SymCModule : array of Integerindefs.inc, the data-side twin ofProcCModule,-1for "not a C module" — the same sentinelCModuleOfTokalready returns. Recorded inCRecordGlobalLinkage, guarded onnot declSawExternand for the same reason as the line above it: a declaration withoutexternis a definition (C 6.9.2) and settles the question, while anexternonly claims import-so-far and must not re-attribute a row another module defined.- RUNG S, data side, in both declaration paths: a
staticdeclaration whose found symbol is a static defined in another module does not seize that row. MirrorsParseCSubroutine's rung including its narrowness — both sidesstatic, both in the current unit, existing row's module known. CFindSymFromModule, mirroringCFindProcFromModulewith two fast-outs: the row already belonging to the asking module never walks, and a row that is not a Cstaticnever walks either.FindSymitself is untouched — it serves Pascal and NilPy, where a C-module preference has no meaning.
Result byte-identical to gcc compiling the same sources as three TUs.
The estimate in this ticket was wrong in a useful direction
It said the cost was the 28 FindSym call sites. It was seven: two on
the value path and five in sizeof. The other 21 are locals, synthesized
names ($vlasz$), or declaration-side lookups that must not be module-aware.
A count of call sites is an upper bound on the work, not an estimate of it
— worth knowing before anyone prices the next lookup-threading job off this one.
The sizeof sites were found by VARYING THE SHAPE, not by reading
With the value path routed, the original 4-row probe was green. A wider probe —
static scalar, array, struct, pointer, address-of, compound assignment,
sizeof — found exactly one shape still wrong:
gcc pxx
sizeof(arr) 12 24 24 24 <- both modules answered B's size
sizeof reads the symbol's own metadata (ArrLen, PtrDepth, AllocSize)
rather than any value, so it resolves at a different set of sites and fails
independently of every read and write assertion. Row 5 of the test exists for
that and is asserted because no value row can observe it.
This is the failure mode of a per-caller rule: it fails by a missing copy, and a missing copy is invisible to a search for the pattern you already wrote. Grep the callee.
Ablations — four, and two corrected claims I had already written down
| ablated | rows |
|---|---|
| whole fix out | 2 / 2 / 70 / 70 / size 24 24 |
sizeof routing only |
1 / 2 / 70 / 2 / size 24 24 |
| RUNG S only | 2 / 2 / 70 / 70 (pre-row-5) |
| value path only | 2 / 2 / 70 / 70 (pre-row-5) |
I wrote "the two arms fail differently" into the test and the Makefile before
measuring, and it is false. The rung and the value-path lookup are each
necessary and neither alone moves a single row — without the rung there is one
row, so the lookup has nothing to prefer; without the lookup the two rows exist
but every reference still takes the first. Both comments corrected to the
measurement. What does fail independently is the sizeof arm, which is the
opposite of what I had claimed and is why row 5 was added.
Population, now with a denominator
53 file-scope static variables across lib/crtl/src/*.c, zero names in more
than one module. The ticket's "zero instances" holds and now carries what it
was counted against.
Two cautions for whoever re-runs that census. test/c_crtl_prototype_pull_module_split.c
states that fcntl.c and unistd.c each have a file-scope static int sysret
— which reads exactly like the counterexample. It is not: sysret is a
function, the already-fixed arm, and neither document lets you tell.
And my first extraction took $NF, so it read the initialiser rather than
the name and reported duplicate statics called 1 and 0. The wrong output
for a duplicate-name census is a list of names, which is the shape a right
answer has — it only broke cover because those two names were digits.
Gate
gate.sh quick GREEN 17/17 including the FPC seed canary, run with
compiler/** uncommitted. Fixedpoint b713783d40d8. sqlite amalgamation
compiles (--emit-obj, 4458 procs); lua.c compiles. sqlite's --threadsafe
build fails at __BEGIN_DECLS both with and without this change — checked
by stashing the change and rebuilding, so it is pre-existing and not mine.
Log
- 2026-09-05 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit ec1a1d7b6.