← board

errno is one global, not one per thread

The measurement

The probe asserts a RELATION, not a constant — "a thread only ever sees the errno its own call produced" — so it carries no per-platform value and is correct against any libc. Two threads, 200000 iterations each: thread A calls open() on a missing path (ENOENT), thread B calls close(-1) (EBADF), and each reads errno back on the very next line and counts how often it sees the other code.

build thread A wrong thread B wrong
gcc / glibc (oracle) 0 0
pxx x86-64 --threadsafe, pinned v403 c31d03b2 4 84
pxx x86-64 --threadsafe, 1968c7a7da57 (frankD, 3 runs) 8 / 21 / 6 12 / … / 8
pxx i386 --threadsafe, 1968c7a7da57 (frankD) 21 26

Nonzero every run, varying as a race should. glibc is 0 every run. Reproduced independently by franks-ab and frankD, on different compiler builds, with separately written probes.

One of the two readings used the PINNED v403 compiler, which rules out "this landed after the pin" — the one alternative explanation available. Two readings that could have failed the same way would have been one reading.

It is WIDTH-INDEPENDENT (i386 row above), and that is an acceptance constraint rather than a curiosity. extern int errno; is a 4-byte int at both widths and TLS-versus-not is orthogonal to pointer size, but orthogonal in principle is not a measurement and now it is one. The usual hazard in this repo is the opposite one — width and alignment bugs being structurally invisible on the x86-64 host that the dev loop, gate.sh quick and the pin all run on. Here the bug is present at BOTH widths, so the risk is symmetric: a fix verified only on the host would look complete.

The rate is a FLOOR, not an estimate. Both probes read errno on the very next line, so the window is a few instructions. Real code does work between the failing call and the check — a log line, cleanup, another call — and the window scales with that work. Do not quote 0.004% as the exposure.

Root cause

lib/crtl/include/errno.h:5

extern int errno;

an ordinary int, where glibc has #define errno (*__errno_location()) and C11 7.5 requires errno to be thread-local. The tentative definition becomes a weak non-TLS object in every translation unit:

pxx object   errno  OBJECT WEAK  4 bytes, section .bss   (non-TLS)
glibc        errno  TLS GLOBAL   4 bytes                 (.tbss)

Why the linker error is the good half

Static linking REFUSES the mismatch outright:

ld: errno: TLS definition in libc.a(errno.o) section .tbss
    mismatches non-TLS definition in obj/coreutils_cat.o section .bss

That is the lucky case: it stops, it names the symbol, and the fix is forced. The dynamic link tolerates it, which is the expensive outcome — every threaded pxx program that reads errno after a failing call can read a value another thread wrote in between, with nothing erroring anywhere. errno is close to the worst variable for this: it is read immediately after a failure and branched on, so a corrupted read becomes a wrong control-flow decision far from its cause.

--threadsafe does not cover it, and that is part of the bug

Without the flag the compiler refuses, and the refusal is a good one:

__pxx_pmutex_init needs the thread-safe runtime: rebuild with --threadsafe
(<pthread.h> lowers onto the pxx thread PAL, which that flag selects)

So the flag exists, it selects a real thread PAL, and threads genuinely run — lib/crtl/src/pthread.c:108 implements pthread_create over __pxx_pthread_create. The flag a reader would expect to make threading correct is exactly the one that leaves errno shared, so nobody gets a warning.

Repro

./stable_linux_amd64/default/pinned --threadsafe errno_race.c out && ./out
gcc -O1 -o oracle errno_race.c -lpthread && ./oracle     # the oracle

Probe source: see the table above for the shape; it is ~40 lines and asserts only the relation, so it needs no expected constants.

Acceptance — a row per target, not one green on the host

If the repair grows TLS symbols in the emitter, each backend has to grow them separately, so one green on x86-64 does not close this. The acceptance wants a row for every target with an object writer.

Assert the RELATION the probe already asserts — zero foreign errno values — never a per-target constant. It passes everywhere, needs no expected value, and therefore cannot be satisfied by an expected value that collides with the failure value. The probe's positive control is free and already demonstrated: the pre-fix build must come out NONZERO, and it does, on every target measured so far.

What the fix needs

extern int errno; cannot become thread-local without the emitter growing TLS symbols, so this is Track A object-writer work, not a lib/crtl edit. Filed from Track B, where it surfaced.

Related: [[meta-a-pxx-produces-linkable-code]] is the standing umbrella for object/link/export work and already records a DIFFERENT errno fix (two objects each reading their own errno, fixed in 243137302 by relocating an exported definition against its own symbol). That one made two objects share one errno; this one is that shared errno not being per-thread. They are not the same bug and the first does not imply the second.

Bound it puts on other work

This was written as a prediction and rung 3 has since measured it, so it is recorded as a measurement. [[feature-b-a-bootable-image-with-the-busybox-userland-on-it]] is DONE (2026-09-04) and its image does carry a dynamic busybox: ldd on the 258-applet binary names libc.so.6 and ld-linux-x86-64.so.2, and the initramfs carries both. gcc -static on the same objects still refuses with the TLS/non-TLS errno mismatch, so the bound held exactly as stated — it did not merely go untested. Anything wanting a single-file static pxx userland waits on this ticket. The same bound applies to frankD's i386 axis. The kiosk finding "pascal26 and everything it emits are statically linked" is true of pxx's own ELF writer output and does NOT extend to anything the separate-compilation path produces, because that path ends in gcc -o out obj/*.o, which links dynamically by default.

2026-09-06 (frankA) — a third independent reading, and a CONSTRAINT that changes "What the fix needs"

Reproduced, separately written probe

At 1b903c1dd, compiler 26b8b0adf442, --threadsafe, 200000 iterations each:

A saw a foreign errno 33 times
B saw a foreign errno  4 times
gcc/glibc oracle:      0 and 0

Third reading, third probe, third session (franks-ab, frankD, frankA). Still live at HEAD.

THE FIX SHAPE STATED ABOVE IS NECESSARY AND NOT SUFFICIENT

"Fix needs TLS symbol emission in the object writer" — measured, and there is a step in front of it: pxx programs run with FS base ZERO, in every thread.

arch_prctl(ARCH_GET_FS, &v)     rc    value
gcc/glibc, main thread           0    7a45fdb9c740
gcc/glibc, pthread child         0    7a45fd7ff6c0     <- distinct
pxx --threadsafe, main thread    0    0
pxx --threadsafe, pthread child  0    0                <- same, and zero

Positive control on the instrument, because "the FS base is 0" and "arch_prctl did not run" print the same 0: the output variable is preloaded with 0xdeadbeef and the syscall's return value is read. rc=0, sentinel overwritten — it ran, and it reports zero.

Emitting errno as a TLS symbol into a process with no FS base does not give it one copy per thread. Every fs-relative access either faults or resolves to the same address in every thread — the bug as it stands, with a .tbss section to make it look repaired. The per-thread TCB has to come first, in the thread PAL's child entry and at startup for the main thread, per target.

And a cheaper path for THIS ticket specifically

errno does not need ELF TLS. glibc's own header is #define errno (*__errno_location()), and the same shape fits here: lib/crtl/include/errno.h's extern int errno; becomes that macro, and the definition at lib/crtl/src/stdio.c:66 becomes a per-thread slot. lib/crtl/src/pthread.c already keeps a 64-slot registry keyed by tid.

The unresolved part is how to find the slot without TLS, and it has a real cost either way: __pxx_pthread_self is a PAL symbol a non---threadsafe build does not link, and gettid(2) per access puts a syscall on every error path. Written down rather than chosen, because the choice is a cost trade-off and not a correctness one.

That path would close the measured race here and would NOT close [[bug-c-__thread-is-accepted-and-silently-ignored-so-thread-local-storage-is-shared]], which is the general form: _Thread_local and __thread are in cparser.inc's tolerate-by-skipping set, so __thread int tv = 7; compiles, runs, prints 7, and emits an ordinary .bss object. This ticket is one instance of a mechanism that does not exist. Worth knowing which one a fix closes before starting it.

Not taken

Diagnosed and parked rather than microfixed. The acceptance above already asks for a row per target, the fix has a live cost fork, and the general form has just been filed beside it — none of that is work to start at the end of an evening. The measurements are banked so the next session does not repeat them.

2026-09-07 — the root is SHARED, not ZERO, and the difference decides the fix's control

Reproduced a fourth time, separately written probe, x86-64 --threadsafe, compiler 014583e713be: thread A saw not-ENOENT 6 times, thread B saw not-EBADF 6 times; gcc/glibc 0 and 0. Nothing below revises the defect.

What is revised is this ticket's stated root. The summary says pxx programs run with "FS BASE ZERO in every thread". That is true in one configuration and false in another, and both are broken:

build main thread
gcc / glibc (oracle) 0x712910136740 0x71290fdff6c0distinct
pxx --threadsafe, raw-syscall probe, no libc import 0 0
pxx --threadsafe, probe importing arch_prctl from system libc 0x7616493e3740 0x7616493e3740non-zero and shared

Sentinel-controlled in every row: the output variable is pre-set to 0xDEADBEEFCAFEBABE, so a syscall that fails and writes nothing cannot be read as "the base is zero" — the failure and the finding would otherwise be the same observation.

The third row is the one that matters. Importing anything from the system C library brings in ld.so, which installs a TCB for the main thread; pxx's own clone stub installs none, so the child simply INHERITS the parent's base. Non-zero, and every thread still resolves an fs-relative access to one place.

So the invariant is "all threads share one FS base", and "zero" is a symptom of one link configuration. This is load-bearing for whoever takes the fix: a control phrased as "the FS base is no longer zero" PASSES on the row above while the defect is fully present. Assert DISTINCT PER THREAD — a relation, like this ticket's own errno probe — never non-zero.

The mechanism, named

lib/rtl/palthread.pas:87PXX_CLONE_THREAD = $350F00. Decoded exhaustively, with the residue checked to zero so nothing is unaccounted:

set    CLONE_VM 0x100  CLONE_FS 0x200  CLONE_FILES 0x400  CLONE_SIGHAND 0x800
       CLONE_THREAD 0x10000  CLONE_SYSVSEM 0x40000
       CLONE_PARENT_SETTID 0x100000  CLONE_CHILD_CLEARTID 0x200000
CLEAR  CLONE_SETTLS 0x80000
       accounted 0x350F00, residue 0x000000

CLONE_SETTLS is not set, and there is nowhere to put its argument: AN_CLONE is __pxxclone(flags, childStack, entry, arg, ctidptr) (defs.inc:1122) — five parameters, no tls. So this is not a missing flag, it is a missing parameter on a compiler intrinsic. Anyone costing the "real TLS" path should cost that, not a constant.

PalThreadSelf is __pxxrawsyscall(SYS_gettid, ...) (palthread.pas:142), which is why this ticket's cheaper path records "gettid per access puts a syscall on every error path" — that is measured now, not assumed: it is the only route to a thread identity that exists today.

Third route, not costed here and not currently in the ticket: thread stacks are mmap'd by PalThreadCreate, so a size-aligned allocation would let a thread find its own block by masking %rsp, with no syscall and no TLS at all. The main thread does not have an mmap'd stack and would need its own case. Recorded so the option is on the table for [[decide-a-a-foreign-thread-needs-its-own-tls-block-and-the-bounds-are-the-hard-part]] rather than rediscovered.

2026-09-07, second correction — THE PER-THREAD BLOCK ALREADY EXISTS. It is GS, and every reading so far measured FS.

This ticket says "A per-thread TCB has to come first." On x86-64 under --threadsafe, it is already there. Measured, five threads, relation asserted (every block differs from every other) rather than any constant:

  main   gs=0x45acb8          <- BSS_TLS_MAIN, the static block
  thread gs=0x7b1f496c3a80    <- carved off the top of its own mmap'd stack
  thread gs=0x7b1f495c2a80
  thread gs=0x7b1f494c1a80
  thread gs=0x7b1f493c0a80
  ALL FIVE DISTINCT

The same probe reports fs=0 in every one of them, and glibc is the mirror image — fs distinct per thread, gs=0. pxx's TLS is GS-relative and the x86-64 psABI is FS-relative ([[decide-pxx-thread-local-storage-is-gs-relative-and-the-x86-64-psabi-is-fs-relative]]), so a probe that reads FS is correct about a register pxx does not use. Every FS reading in this ticket, mine included, is an instrument answering accurately about something else.

So the defect is not missing thread-local storage. It is that errno never went into the storage that exists. lib/crtl/include/errno.h:5 is extern int errno; — an ordinary .bss object — while compiler/defs.inc carries a slot map (TLS_SLOT_SELF, _TID, _STACK_LO/_HI, _SIG_*, _EXC_*, _HEAP_MAGBUSY) with TLS_SLOT_FIRST_FREE = 13 and the magazine's tail starting at 16 — three free slots, and errno needs one. __pxxTlsBase (AN_TLSBASE, pasparser_expr.inc:4023) already returns the calling thread's block and runs inside parallel workers today.

What this changes about the fix, and the part that is still hard

A slot count in prose is a census with an owner elsewhere: re-read TLS_SLOT_FIRST_FREE from defs.inc before taking one. It was 12 when the comment above it was written.

2026-09-07 — THE GROUP, MEASURED INDEPENDENTLY (frank-subcoord). One cause, four symptoms, and the existing test cannot see it

Taken as the entry point to a group rather than alone. Three open A tickets state one root cause in their own words — and three summaries agreeing is not three measurements, so I reproduced it rather than citing them.

The measurement, two-armed so it cannot be silently broken

--threadsafe, compiler 19bee89a03e635cf. Arm P is the positive control in the same binary on the same run: if BOTH arms had come back identical, the honest reading is a broken probe, not a universal defect.

main              4369368
pxx    thread 0   128824687176320     arm P: PalThreadCreate (runs the clone stub)
pxx    thread 1   128824683494016
pxx    thread 2   128824682441344
pxx    thread 3   128824681388672
foreign thread 0  4369368             arm F: libc pthread_create
foreign thread 1  4369368
foreign thread 2  4369368
foreign thread 3  4369368

arm P (control, MUST be 0): colliding pairs = 0
arm F (subject)           : colliding pairs = 6
arm F equal to MAIN base  : 4 of 4

A thread libc created reads the MAIN thread's block. Every gs: slot it touches is the creator's.

THE ASSERTION IS DISTINCTNESS, NEVER NON-ZERO. A foreign thread INHERITS a valid base, so the failing value is non-zero and dereferences fine — the expected value collides with the failure value, and this ticket already records a fix whose "no longer zero" control passed while it was still broken.

The existing test passes while this is live, and that is a wrong-population control

test/test_tls_base.pas exists to assert "blocks genuinely distinct per thread" and covers three phases — 0 main, A clone-stub, B manual arch_prctl. Neither it nor test_glibc_tls_coexist.pas contains pthread_create. Every thread it tests is one pxx created, which is exactly the population that works. The control is drawn from the wrong population, so it cannot fail for the case DOSBox, SDL and every threaded C library produce.

A FOURTH symptom, not yet ticketed: the signal slots

bug-a-the-parked-signal-slots-are-process-wide-and-race-across-threads was fixed by moving TLS_SLOT_SIG_CODE/_ADDR/_CTX/_NUM into the per-thread block (75875 wrong answers in 400000 deliveries -> 0). defs.inc:900 explains why those four are deliberately NOT bounds-validated: a SA_ONSTACK handler runs with rsp on the sigaltstack, so a bounds check would answer "not my block" on every delivery — and the justification given is "the writer and the reader here are the same thread with the same base, so they always agree".

That premise is false for a foreign thread, by the same argument this ticket's siblings make: it holds the creator's base. StatusSlotTlsIndex (exception_emit.inc:24) maps both the SIG and the EXC families to gs: slots and its own comment says "Decided entirely at EMISSION. No runtime branch exists anywhere downstream." So two libc threads taking signals share all four slots and the race that fix removed is live again for them.

Labelled honestly: the two premises are measured (bases shared, above; no runtime ownership branch, read at exception_emit.inc:24-42), the RACE itself is not. It needs the original ticket's probe re-pointed at libc threads.

What the group actually costs, which reorders the fix

The ownership test that would catch all of this already exists and is inheritance-proof: compare the reader's own rsp against TLS_SLOT_STACK_LO/ _HI, because a block copies byte-for-byte across clone but a stack cannot (defs.inc:858-877). It is emitted at two sites (ir_codegen.inc:1248, :3460) and both are tid sites whose fallback is a gettid syscall — they RECOMPUTE a value.

errno and EXC_TOP cannot do that: they need per-thread storage, not a recomputable value. And EXC_TOP is touched on every try entry and exit, where the emitter's own comment records refusing even a single mov rax, gs:[0] on cost grounds — so a four-instruction bounds check per access is not available there either.

So the three consumers sort into three different fixes, and "apply the existing check everywhere" is not one of them:

consumer needs status
I/O lock, owner tid a recomputable VALUE done — bounds check, gettid fallback
heap magazine mutual exclusion only done — ba2682d2f made a shared magazine correct
errno, EXC_, SIG_ per-thread STORAGE open — this is the real design question

That last row is the group. Giving a foreign thread a real block pays once at thread entry instead of per access, which is the only option the hot path tolerates — and the heap ticket already called it "the better answer ... a design question, not a bug fix."

Probe kept at scratchpad/tls/probe_foreign_tls.pas; it is a FAILING test today so it cannot land in test/ as a green gate, and it is the natural phase C of test_tls_base.pas once the storage question is settled.

PARKED 2026-09-07 (frank-subcoord) — free to take, nothing half-done

Fleet dropped to two working seats and this one is idling. Nothing is in-flight and nothing is half-edited: the group finding above is measured, recorded and pushed, and no code change was started.

Resume condition: none. This is not blocked on anything — it is unstaffed. The next seat can start from the table at the end of the section above; the open question is the third row (per-thread STORAGE for errno, EXC_*, SIG_* on threads pxx did not create), and the two decided rows are recorded so they are not re-litigated.

The probe that reproduces the root cause is at scratchpad/tls/probe_foreign_tls.pas in this session's scratchpad, which is reaped after 6h — it is ~90 lines and the section above gives its full design (two arms, control in the same binary, assert distinctness never non-zero), so rebuild rather than hunt for it.

STILL LIVE at 7addc40f08af, and THE STATED BLOCKER DOES NOT HOLD — 2026-09-16 (frankS, Track A)

Reproduced a FOURTH time, separately written probe, same relation (a thread only ever sees the errno its own call produced), 200000 iterations each:

build thread A wrong thread B wrong
gcc / glibc (oracle) 0, 0, 0 0, 0, 0
pxx x86-64 --threadsafe, 7addc40f08af 8 / 3 / 1 / 2 / 0 / 1 0 / 4 / 2 / 2 / 0 / 0

Nonzero and varying as a race should; the oracle is 0 every run. The counts are an order of magnitude below the 4-84 recorded above — do not read that as progress toward a fix, nothing in the errno path changed; the thread route did (02b7f7250, bc3ab775e, and the pthread route), so the window moved. errno.h:5 is still extern int errno;.

The target-set objection was the thing blocking this, and it is measured false for OUR threads

This ticket's own superseded note concluded the storage already exists (GS-relative, TLS_SLOT_FIRST_FREE = 13, three free slots, __pxxTlsBase returns the caller's block) and then parked the fix on: "it fixes no FOREIGN thread on any target because one that libc created never runs the clone stub that carves the block."

That is true of a genuinely foreign thread and NOT true of any thread a pxx program makes, including from C. lib/crtl declares its own pthread_create (lib/crtl/include/pthread.h:76), so C code compiled by pxx does not reach glibc's — it reaches PalThreadCreate, and on x86-64 with libc linked that takes the pthread route through PxxPthreadStart, whose job is "exactly what the clone stub's child leg does for a cloned one — install pxx's gs block". Measured both routes, every thread PROVABLY ALIVE AT ONCE (see the method note below):

route distinct gs blocks
Pascal PalThreadCreate, 4 threads 4/4, none equal to main
C pthread_create (crtl's), 3 threads, gs read by arch_prctl(ARCH_GET_GS) 3/3, none equal to main

So the per-thread block exists for the population this ticket is about, and the cheap path — a free TLS slot plus #define errno (*__pxx_errno_location()), mirroring glibc's own header — needs no CLONE_SETTLS, no AN_CLONE arity, no .tbss and no per-access gettid.

METHOD, because the first attempt got the opposite answer and it was the probe, not the tree. Short thread bodies gave 2 of 3 blocks EQUAL — glibc recycles a finished thread's stack, the block is carved off that stack, so a recycled address reads exactly like two live threads sharing one. Holding every child at a spin until the parent has seen all of them arrive gives 4/4 and 3/3. Assert DISTINCT WHILE CONCURRENT; distinctness sampled across a thread's death is not the same claim, and it is the claim that fails.

Residual, narrow and real: a thread created by an EXTERNAL shared object calling glibc's pthread_create directly never runs PxxPthreadStart and does inherit the creator's gs (PxxPthreadStart's own comment says so). Such a thread would share errno. That is a smaller population than "every libc-made thread", which is what this ticket had assumed.

Not fixed here: the remaining work is Track C (every crtl site that SETS errno must go through the accessor), which is a lane and a scope beyond this session's group. The diagnosis is the deliverable; nothing above is inert until a pin, because nothing above changed code.

RESOLVED 2026-09-19 (frankS) — errno is __thread, and the guard is a capability macro

The fix is two lines and neither of them is new machinery

lib/crtl/include/errno.h declares extern __thread int errno; and lib/crtl/src/stdio.c defines __thread int errno;. That is all. __thread already works on hosted x86-64 scalars — errno is exactly that — and TryAssignThreadVarStorage already allocates from the threadvar area, so this needed no new slot, no TLS_SLOT_ERRNO, no CLONE_SETTLS, no .tbss and no gettid on the error path. The ticket's own superseded-2026-09-07 paragraph had this right: "errno simply never went into the storage that exists."

The tid-registry alternative this ticket also records was measured and rejected, not skipped: PalThreadSelf is a raw gettid syscall, so that route puts a syscall on every errno access.

Measured

before after
x86-64 --threadsafe, 200000 iters/thread cross_reads a=8 b=0ERRNO-TLS SHARED a=0 b=0ERRNO-TLS OK, 8 runs of 8
glibc oracle, same probe 0 0 0 0
x86-64 single-threaded errno=2, cleared=0 unchanged

The guard is __pxx_thread_local__, NOT __x86_64__, and that distinction is the whole second half of this work

__thread off its supported configuration degrades to one shared .bss object and warns. errno.h is reached by nearly every C file — lib/crtl/include is an auto-registered <> search path — so the unguarded spelling put a warning on essentially every C compile in the tree, against cparser.inc's own contract that "a warning that fires on everything is not a warning."

#ifdef __x86_64__ is the obvious guard and it is wrong in a direction that matters: one of the allocator's refusals is TLSREFUSE_NOINSTALL--emit-obj/--shared have no ELF entry point, so nothing installs the block — and that is busybox's per-TU build, ~1,800 objects, every one of them including errno.h. Measured: --target=<cross> 1 warning each, and x86-64 --emit-obj and --shared 1 warning each as well.

So compiler/cpreproc.inc now predefines __pxx_thread_local__ from the same two TU-level conditions TryAssignThreadVarStorage refuses on (TLSREFUSE_ARCH, TLSREFUSE_NOINSTALL) — both command-line facts known before a token is read — and both spellings of errno branch on that one macro, so the declaration and the definition cannot drift onto different arms. It deliberately does not claim the other three refusals (ARRAY, TYPE, AREAFULL): those are properties of a declaration, not of a translation unit.

Warning cost of a TU that merely includes errno.h, after:

--target=i386/arm32/aarch64/riscv32   0    (was 1 each)
x86-64 --emit-obj / --shared          0    (was 1 each)
x86-64 hosted                         0

warnings=0 everywhere is also exactly what a macro that is never defined would print, so that row is not the evidence. Two controls separate them: an explicit __thread the guard does not touch still warns 1 in all five degrading populations and 0 on hosted x86-64; and an #error probe reports the macro DEFINED in exactly one population, x86-64 hosted. (That probe first answered DEFINED for xtensa too — rc != 0 from xtensa's unrelated refusal of a standalone C executable. Matching the error TEXT, not the exit status, fixed it.)

The test asserts a COUNT, because a value check cannot see this

test/c_errno_is_per_thread.c, wired into test-core. A shared errno is a race, so errno == ENOENT on one iteration passes almost every time — the assertion class has to match the defect class. It carries its own positive control: a deliberately shared int written before the same syscall and read after it, so it spans errno's window. The first version of that control stored and reloaded on adjacent lines, a two-instruction window, and reported zero crosstalk while genuinely shared — which would have been read as "the threads overlap fine" and made the errno row meaningless.

Fixture verified against the real pre-fix sources (git checkout HEAD -- on both files, not a simulation): RED 5 runs of 5; against the fix, GREEN 5 of 5.

What this does NOT fix, and it is measured, not assumed

A FOREIGN thread still shares errno — one that never runs PxxPthreadStart/the clone stub inherits its creator's gs, and errno lives in that block. Measured at HEAD today with both routes in one program: four BeginThread threads get four distinct bases; four threads from a direct external 'libpthread.so.0' pthread_create all report the main thread's base. That is [[bug-a-a-foreign-thread-shares-the-main-thread-s-heap-magazine]], which reproduces and is the genuine sibling of this ticket.

The residual is narrower than it sounds for C specifically: lib/crtl defines pthread_create itself (lib/crtl/src/pthread.c:108__pxx_pthread_createPxxPthreadStart), so a pxx-compiled C program's threads ARE trampolined and DO get their own block. That is why the race above measures 0 rather than passing by accident. A foreign thread in C therefore means a linked external .so starting its own thread, not an ordinary pthread_create call.

Off x86-64 errno is still shared, unchanged from today and byte-identical to before: __thread has no per-thread block to use there. That is the arch half of the same sibling ticket.

Log