← board

Six reentrant variants are absent, not merely unused

What was measured

A census of file-scope static non-const objects in lib/crtl/src/*.c: 46 objects across 15 of 32 modules. A first pass reported 163 in stdio.c alone — that regex was matching prototypes and extern declarations, so it is not the number here and is recorded only so nobody reproduces it and believes it.

The count is not the finding, and a census of statics is the wrong instrument for the question. POSIX explicitly permits strtok, asctime, ctime, rand, strerror, the getpw*/getgr*/getserv* families and getopt to keep static state. A static there is the standard working as designed. The real question is whether the escape hatch exists:

present  (def in lib/crtl/src AND decl in lib/crtl/include):
  strtok_r  strerror_r  asctime_r  ctime_r  gmtime_r  localtime_r

ABSENT   (no definition anywhere in lib/crtl/src, no declaration
          anywhere in lib/crtl/include):
  rand_r  getpwnam_r  getpwuid_r  getgrnam_r  getgrgid_r  getservbyname_r

Why the absent six are a different class

Where the _r exists, the plain call racing is not our defect — it is the documented contract, and a threaded program that wants safety has somewhere to go. Where it does not exist, a threaded C program has no correct call in our libc: the plain spelling races and the reentrant spelling will not link. There is no workaround available to the program's author.

Fix shape

Each is a small function over state the plain version already computes; the plain one becomes a wrapper over the _r writing into its existing static, which is how they are normally related and avoids a second copy of the parsing. rand_r is the smallest (__crtl_rand_state is one unsigned long long in stdlib.c) and is a reasonable first one.

Positive control for any fix

A row that links and calls each _r, and — because "it links" is also what a declaration with no definition can look like until the linker runs — asserts the RESULT differs from a second call with a different buffer. An expected value that collides with a default would not separate a working _r from a stub returning zero.

Scope note

This is about ABSENCE, not about the races. The still-open per-thread-state work is [[bug-a-a-foreign-thread-shares-the-main-thread-s-heap-magazine]].

RESOLVED 2026-09-19 (frankS) — all six implemented

rand_r (stdlib.c), getpwnam_r/getpwuid_r (pwd.c), getgrnam_r/getgrgid_r (grp.c), getservbyname_r (netdb.c), each declared in its own header.

The fix shape this ticket prescribed was WRONG, and backing it out is the finding

The "Fix shape" section above says "the plain one becomes a wrapper over the _r". I wrote it that way first. It is a regression and it does not show up on a normal file, which is why it is recorded here rather than quietly changed — this is an assertion-written-from-a-prediction, and the prediction was mine.

The two forms must answer an over-long line differently:

over-long line
plain SKIP it and keep looking — each file's header says so, and argues it: a skipped line at worst answers "no such user", a truncated one invents a user whose home directory is a prefix of the real one
_r ERANGE — POSIX requires it, and the caller chose the buffer, so silently skipping the entry they asked for would be a wrong ANSWER rather than a failure

Routed through the _r, one over-long line anywhere in /etc/passwd turns getpwnam() into "no such user" for every entry after it.

grp.c and netdb.c have a second, independent reason: the plain forms get dedicated GR_MEM_MAX/SERV_ALIAS_MAX (256) pointer arrays, while the _r forms carve the member/alias array out of the caller's buffer. Wrapping would shrink a long group's member list to however many pointers fit after the line — wheel:x:10:alice,bob reported without bob, on a file that works today.

What IS shared is the parser, which is where duplication would actually hurt: pw_parse/gr_parse/serv_parse now take their output struct (and, for the two with lists, the array and its capacity), so there is one copy of "what the fields mean". The residual overlap is a four-line match loop, and the two loops differ in exactly the way the table above says they must.

rand_r cannot share rand()'s generator either, and that is forced by the signature: POSIX types the seed unsigned int, so 32 bits of state where rand() keeps 64 — and rand()'s whole design is to return the HIGH half, because a power-of-two LCG's low bits have short periods. With 32 bits there is no high half. It uses the classical three-round construction; the range still matches RAND_MAX, and the two sequences differ, which is conforming.

Test

test/c_crtl_reentrant_lookups.c, wired into lib-test beside cpwd.

Rows are relations — each _r answer against the plain one on the same input — so no per-machine constant is baked in (root's shell and ssh's port differ per box). Agreement alone is not enough, though: a "reentrant" function that called the plain one and returned its static pointer would pass every agreement row, which is the exact bug class this closes. So the load-bearing assertion is by address: the returned strings must live inside the CALLER'S buffer and the plain form's must not. A stub cannot fake that. Plus a two-buffer row proving both results stay valid simultaneously, an ERANGE row, and for rand_r a not-a-constant row (a stub returning 0 satisfies every range and determinism check).

Oracle-checked, with one deliberate divergence

Built with gcc against glibc: every row passes except one, and that row is a real contract fork, measured — glibc returns ENOENT for a not-found entry where POSIX says "0 shall be returned and result shall be set to a null pointer". Linux's own getpwnam_r(3) records that implementations deviate.

We follow POSIX, and it is also the safer direction rather than merely the purer one: both set *result to NULL, so the portable idiom works under either, while code treating any nonzero rc as a hard error gets one from glibc on an ordinary missing user and not from us. The fixture names that row so the gcc failure explains itself, and says not to "fix" it by matching glibc.

Not inert until the next pin

lib/crtl sources are compiled live, not carried in the pin, so this works under $(PXX_STABLE) today — verified by building the fixture with stable_linux_amd64/default/pinned: fails=0. The three existing fixtures that touch these families (cpwd, c_crtl_netdb_and_exec, c_crtl_busybox_surface) are still byte-identical to gcc after the parser refactor.

Log