← board

Why it is a fixed cap, in the code's own words

EmitTlsMainInstall emits the program's entry prologue at code offset 0 and is called from compiler.pas:2330before any frontend parses anything — so it reserves the block and bakes the size into emitted immediates before a single threadvar has been seen. defs.inc says so and calls growing it out of scope for the first rung. That is still true; what follows is about getting the 3,072 back for programs that never needed it.

ROUTE C SHIPPED 2026-09-18 — the size is a command-line knob

-dPXX_TLS_USER_0 / _1K / _2K / _4K / _8K / _16K; default unchanged at 3072. -dPXX_TLS_USER_0 gives the whole 3,072 back to any program that declares no threadvar, which is most of them, and it needs no detection at all.

It is sound for the reason the measurement below established and nothing more: the fold and the reserve capture the size at a moment, and a capture is safe exactly while the value cannot move afterwards. argv is read before any of it, so a -d cannot violate that; what is forbidden is a size that VARIES DURING a compile, not a size that differs BETWEEN compiles. Route B is the one that breaks it, and it is still the only one that does.

Measured on an x86-64 hello, bss at each rung — exactly the area's own difference every time:

0 1K 2K 3072 4K 8K 16K
bss 35,324 36,348 37,372 38,396 39,420 43,516 51,708
__pxxTlsBlockSize 1152 2176 3200 4224 5248 9344 17536

test_atomic_counter prints ATOMIC OK at 0, 1K, default, 8K and 16K; test_a_threadvar_is_per_thread is correct at default, 1K and 16K; the relation-asserting test_the_tls_carve_constants_are_readable_from_the_rtl passes at default and at 8K without editing — palthread reads the real size through __pxxTlsBlockSize and follows the knob.

Quantised to powers of two for the same reason the ESP heap arena is: a define carries no value usable in an expression, so an arbitrary byte count would mean a new option and a parser change, and a per-thread variable area is not tuned to the byte.

Fixture test_the_threadvar_area_is_a_command_line_knob.pas, both directions: 385 Int64 threadvars = 3080 bytes, refused at the default and accepted at -dPXX_TLS_USER_4K, with the LAST-declared name — the one past the default cap — read back, so a knob that moved the diagnostic without moving the storage reddens.

Routes A and B are NOT closed by this and should be read as what they now are: route A buys the same 3,072 without the user asking, on the 22% of examples/ with no top-level uses; route B buys it on every program and still needs the fold fixed first. Route C is the cheap floor under both.

ROUTE A SHIPPED 2026-09-19 — no flag needed, and the floor subject is covered

A Pascal program whose source names neither threadvar nor uses is given a zero-byte area automatically. hello.pas — the hosted umbrella's floor subject — goes bss 38,396 -> 35,324 with no flag, and __pxxTlsBlockSize becomes 1152, the slot map alone. The size canary records the same -3,072 on x86_64-empty.

MY OWN RESUME NOTE WAS WRONG ABOUT THE CENTRAL FACT AND THIS IS THE CORRECTION. It said to scan in DetectPascalRuntimeNeeds and that "route C built the ordering route A needed; there is no plumbing left to do". The ordering is right and the instrument was not: the token array is EMPTY at the only moment the size may be chosen. compiler.pas clears TokCount well above ApplyTlsUserBytesOption, and every frontend lexes inside its own branch BELOW it; DetectPascalRuntimeNeeds runs later still, inside ParseProgram. Nor can the call move down — EmitTlsMainInstall bakes TlsBlockSize into the BSS reservation and two immediates, and the fold captures it.

So the scan reads Source, which by that point is the main file with its includes already expanded (ExpandIncludes then ExpandPasMacros, both under IsPascalFrontend — which is why the feature is Pascal-only and that is a consequence, not caution: a C program's #includes are expanded later, so a __thread in a header would be invisible, and NilPy resolves imports later still).

Every way the scan can be wrong costs bytes, not correctness, and that is the design rather than a hope: the word in a comment or a string reserves an area that would have been reserved anyway. Demonstrated by accident and kept as the cheapest illustration anyone will find — the first draft of test_a_unit_free_program_pays_no_threadvar_area.pas explained itself using both keywords, tripped its own scan, and printed 4224. The test asserting the feature had disabled the feature. The file now says so at the top.

Why the uses rule stays, and what it costs

Kept, and the reason is sharper than "conservatism": dropping it would refuse a program that compiles today. A threadvar in a used unit is not in the main source's text, so without the rule such a program meets the cap and is refused until someone passes a flag. Trading an acceptance regression for bytes is not an optimisation. It caps the reach at unit-free programs — 11 of 49 Pascal files under examples/, counted 2026-09-18 and not re-counted; hello.pas qualifies, which is the case the umbrella cares about.

The rule currently defends a hypothetical, and that is worth knowing rather than acting on: NO unit under lib/ or compiler/builtin/ declares a threadvar (counted 2026-09-19 — sockets.pas says in its own words that there is none in this dialect, and the three files a naive grep returns are all PROSE). That matters because a uses-free program still pulls builtinheap ambiently, so the prescan depends on it. It is GUARDED, not merely noted: test_a_unit_free_program_pays_no_threadvar_area asserts block=1152, and the first ambient-unit threadvar turns it red with the cap's own diagnostic.

Found on the way, and filed rather than chased

bug-a-a-threadvar-in-a-units-implementation-section-silently-reads-zero (p55). A threadvar in a unit's IMPLEMENTATION section is allocated storage but never rewritten, so it reads 0 with no diagnostic; in the INTERFACE it works. It reproduces on the pinned compiler and is independent of everything here — the program that exposes it has a uses and therefore gets the full default area under every setting.

THE NILPY ARM SHIPPED 2026-09-19, AND THE BLOCKER I RECORDED DOES NOT EXIST

The section below said this arm was gated on defs.inc's PyImportLang caveat -- isNilPy is true for the WHOLE compilation including the Pascal RTL units a NilPy program drags in, so "the frontend has no such keyword" is not "this compilation cannot declare one". The caveat is real and it is not a blocker, and the reason is one measurement nobody had taken.

What I was afraid of was a SILENT collision of per-thread state. It is not silent. A threadvar in a unit a NilPy program imports reaches the PASCAL allocator, which errors:

$ pascal26 -dPXX_TLS_USER_0 np.npy o
pascal26:3: error: threadvar counter: the per-thread variable area is full (0 bytes)...
  in: tvnp.pas
  near: ; interface threadvar counter : LongInt >>> ; function Bump
(exit 1)

It names the unit, the line, and the flag that fixes it. So the worst case on this arm is an acceptance regression that announces itself with its own remedy -- the same class as the Pascal uses rule, and today over a population of ZERO, since no unit under lib/ or compiler/builtin/ declares one. test_tlsnonenp26 is the guard: a NilPy fixture that imports a real Pascal unit ON PURPOSE, to put one on the chain, and asserts block=1152. The day an ambient unit declares a threadvar it fails to BUILD with the diagnostic above, rather than shipping a wrong size.

Measured: a NilPy hello goes bss 62,724 -> 59,652, a flat -3,072, and __pxxTlsBlockSize 4224 -> 1152. The pinned compiler answers 4224 for the same fixture, which is the positive control.

C IS EXCLUDED, and not out of caution. Three things, the third deciding: C has __thread; its #includes are expanded AFTER the size is chosen, so no text scan here could see a header's declaration; and -- measured the same hour -- the C arm of TryAssignThreadVarStorage warns where Pascal errors:

pascal26:2: warning: __thread counter: the per-thread variable area is full (0 bytes)...
  -- this declaration therefore gets ONE copy shared by every thread, not one
  per thread ... threaded code will read and write another thread's value with
  no further warning

and the program compiles, links and runs. A hard refusal naming a flag is a fine failure mode; a warning that silently converts a thread-local into a shared global is not. C keeps the full 3,072 bytes and that is the right answer, not a gap. Changing it means giving C's arm the Pascal arm's refusal, which is a separate decision about a frontend I do not own.

WHAT IS LEFT (frankS parked it 2026-09-19; nobody holds it)

The ticket stays OPEN on two remaining pieces, in value order:

  1. The other frontends. NilPy SHIPPED (see the section above); C is excluded for a measured reason and is not coming back without a decision about its allocator arm. What remains of this item is the LONG TAIL -- BASIC, Rust, Zig, Erlang, Ada, Algol, Fortran, Lol, Ws -- each a flat -3,072 and each worth exactly one or Is<X>Frontend term once someone confirms that frontend's ambient chain refuses loudly the way NilPy's does. Nobody has asked for those, so they are not scheduled. The original text follows.

    The other frontends. NilPy, BASIC, Rust, Zig, Erlang, Ada, Algol, Fortran and the rest cannot declare a thread-local at all — only Pascal (threadvar) and C (__thread) reach TryAssignThreadVarStorage, which is a two-file census, not a guess. Each of them is a flat -3,072 (measured: a NilPy hello 62,724 -> 59,652; a C hello 72,448 -> 69,376 at rung 0). The catch is the one defs.inc spells out at PyImportLang: isNilPy is true for the WHOLE compilation, including the nested uses of every Pascal RTL unit a NilPy program drags in — so "the frontend has no such keyword" is NOT the same claim as "this compilation cannot declare one", and the same ambient-unit argument as above has to carry it.

  2. Route B remains the only unsound one and its blocker is unchanged.

Two routes, and they are not equal

A — conservative prescan, safe in every direction, worth less. Scan the token array for an identifier threadvar before EmitTlsMainInstall, the way DetectPascalRuntimeNeeds already scans for tkUses/tkArray/tkClass. Absent → the user area is 0. uses must also force the area back on, because a used unit's threadvar is not in the main source's tokens; that is the same opacity rule the existing prescan uses. Non-Pascal frontends have no threadvar at all and can always take 0.

So it wins on unit-free programs — which is exactly the hosted umbrella's floor subject, and nothing else. Counted 2026-09-18: 11 of 49 Pascal files under examples/ have no top-level uses, so route A reaches 22% of them. That is the honest ceiling on this route; decide against it rather than discovering it.

B — exact, worth the full 3,072 on every program, and it hinges on ONE fact nobody has established. Reserve the area from what parsing actually used (TlsUserUsed) and patch the handful of size immediates afterwards — the compiler already patches code with Patch32, and the clone stub is emitted lazily (CloneStubAddr := 0) so it may not need patching at all.

Read this before you delete the reserve, and before you believe it is a guard

Two readings were put to this ticket and both are half right; the measurement separates them.

So the constraint is not "do not change the size". It is "do not let the size VARY DURING A COMPILE". That is what decides the two routes below: route A fixes the size before EmitTlsMainInstall, i.e. before palthread is ever lexed, and is therefore exactly as sound as today at ANY size including 0. Route B grows it as threadvars are parsed, and is the only one that breaks the fold.

A reader who deletes the reserve without doing route A's prescan gets the COMPILE ERROR above, not corruption — so the failure direction is safe in both directions here, which is unusual and worth knowing.

Route B's blocker

__pxxTlsBlockSize FOLDS EARLY — established 2026-09-18 from the mechanism, not from a comment. lib/rtl/palthread.pas:280 reads the block size from __pxxTlsBlockSize rather than restating it, and pasparser_expr.inc:4451 lowers that name to AllocNode(AN_INT_LIT) with ASTIVal := TLS_BLOCK_SIZE at the use site, while palthread is being parsed. Its own comment states the intent — "Constants, not calls — they fold to an AN_INT_LIT here, so a const in the RTL can be defined from them."

So if the block size becomes a value that GROWS when a threadvar is seen — which is route B and only route B — that literal captures whatever it was when palthread was parsed, which can be BEFORE the growth. Too small a block means gs-relative slots write past the mapping — the exact silent corruption palthread's own comment warns about, arriving from inside one compile instead of between two releases.

Route B therefore needs the builtin to resolve LATE — a patched immediate recorded like the other Patch32 sites, or a relocation — before any of the reservation work is worth starting. The const-definability the comment cites is currently unused: palthread.pas:280 is the ONLY consumer in lib/** and it assigns to a local, so nothing today actually depends on the literal form.

PAL_MIN_STACK (palthread.pas:85) restates 4224 as part of a stack FLOOR. A smaller block only makes that floor more generous, so it blocks neither route — but it is a second copy of a number that has already moved once, and it should be derived rather than restated whichever route is taken.

Do not re-measure these