← board

The question, in one sentence with no implementation noun in it

Do we want a C program with threads to be able to use stdio, or is pxx's C runtime single-threaded and honest about it?

Everything else here is engineering and is ours. That sentence is not.

What is actually in the code

lib/crtl/src/stdio.c:

static FILE __crtl_files[16];

static FILE *__crtl_alloc_file(void) {
  int i;
  for (i = 0; i < 16; i++) {
    if (!__crtl_files[i].heap) {
      __crtl_files[i].heap = 1;
      return &__crtl_files[i];
    }
  }
  return 0;
}

Test-then-set with no atomic. Two threads can both read heap == 0 for the same slot and both be handed that FILE *. The same file says at line 1181 that the _unlocked variants differ from the plain ones "only by skipping the per-FILE lock, and crtl's FILE has no lock", and at 1207 that the lock operations themselves are no-ops "for single-threaded streams". So this is not an oversight anyone hid; it is a stated stance, and the stance is what needs re-deciding now that threaded C runs.

pxx_popens[16] in the same file has the same shape.

Both probes, and why neither is evidence

Recorded so the next reader does not spend the same hour.

Probe 1 — two threads, 20000 iterations each, fopen/fclose in a loop, counting iterations where both held a non-NULL FILE * and where those were equal:

overlap=6  same-FILE-handed-to-both=0
overlap=4  same-FILE-handed-to-both=0
overlap=0  same-FILE-handed-to-both=0   <- reported "PROBE PROVED NOTHING"

Overlap of 4-6 in 20000 is the finding: fopen+fclose is fast and the test-then-set window is a handful of instructions, so the threads almost never occupy the allocator together. The third run overlapped zero times, and the only reason that zero was not read as a clean bill is that the probe asserted its own precondition and said so.

Probe 2 — aimed properly: main holds 15 of the 16 slots so both threads scan the same taken entries and arrive at slot 15 together, turning the window into a rendezvous. It deadlocked in its own spin logic and was killed at 60s. That is a fact about the harness, not about the code.

What the decision changes

If thread-safe stdio is wanted: the allocator needs a compare-and-swap on heap (the pattern already exists — ba2682d2f made the heap magazine's ownership guard an xchg r64, m64 for exactly this class), and flockfile stops being a no-op. An unguarded test-then-set on a shared table arguably justifies that on the code shape alone, without a demonstrated race.

If it is not wanted: say so in stdio.h, keep the no-ops, and this closes as known-incompat — a true and reproducible property that is chosen rather than tolerated. What is NOT acceptable is the current middle, where the code looks like it might be safe and the comment saying otherwise is 1100 lines away from the allocator.

Why it is live now and was not before

Measured 2026-09-19 in the per-thread-state group: lib/crtl defines pthread_create itself and routes it through PxxPthreadStart, which installs a real per-thread TLS block; errno became __thread the same day. Threaded C on pxx now runs and gets correct per-thread errno. stdio is the next thing such a program touches.

Do not re-probe this

If the decision is "make it safe", the cmpxchg is justified by the code shape and a probe is not required to authorise it. If a demonstration is genuinely wanted, widen the window INSIDE the allocator (a debug build with a delay between the test and the set) rather than around it — probe 1 shows that arranging contention from outside does not reach a window this narrow.