← board

A libc pthread inherits its creator's exception chain

RE-MEASURED 2026-09-16 -- THE REPRO IS A COIN FLIP, AND ONE RUN CANNOT VERIFY A FIX

Everything below this section stands. What it does not say, and what anyone who works this ticket needs before they start, is that the repro's exit code is nondeterministic at a FIXED optimisation level with a FIXED binary -- and one of its outcomes is SUCCESS.

Measured 2026-09-16, compiler b57f90696a01, --threadsafe, 30 runs per level, one binary per level built once:

level exit 0 124 (timeout) 139 (SIGSEGV) 217 (unhandled exception)
-O0 -- -- 3 27
-O2 5 1 1 23

The row that matters is -O2 exit 0: the repro PASSES about one run in six. So a future fix for this ticket, verified the ordinary way with a single run, has roughly a one-in-six chance of reading as FIXED on luck alone -- and the same coin decides whether a REGRESSION is seen. Verify any change here over at least 30 runs per level and report the distribution, never a single exit code. The 139 and 124 rows say the same thing from the other side: this defect's observable is not one behaviour but four, and a ticket that says only "prints Unhandled exception" under-describes it enough to mislead.

Found from the other end, by frankuser, because optdiff#shard9/12 went red naming a 12-commit range with no cause in it. The mechanism is a population error in the harness: tools/optdiff.sh:122 enumerates every .pas and .c file under the test/ directory, so this program is swept even though its own summary says it is NOT WIRED because it fails. That population is "files in test/", not "tests". A one-run-per-level differential cannot express a question about a program whose exit code is a coin flip, so tools/optdiff.skip now carries it; the skip is on the INSTRUMENT and this bug is untouched and still open at p70, which is the right split and is recorded here so nobody reads the skip as a downgrade. The auto-filed regression ticket naming that range was corrected and moved to rejected/ -- nothing in the range is causal, and the PINNED v410 compiler, which predates all of it, flakes identically.

The numbers above are this seat's own runs, not a relay: the distributions differ in detail from frankuser's (they saw outcomes at levels where I did not), which is what a race looks like and is itself part of the finding.

RE-MEASURED 2026-09-15 at 561c30f6409b9376 -- STILL LIVE, AND THE BLAST RADIUS IS NOW BOUNDED

Re-run because the thread-pointer work landed 2026-09-14 (palthread.pas now makes threads through a weakly-imported libc pthread_create) and nobody had re-measured this against it. A parked test whose blocker may have moved is worth one command; this one had not moved, but the SCOPE turned out to be much narrower than the ticket as written implies, and that is the new information.

raw pthread, 300k each   5 runs: 4x "Unhandled exception",
                                 1x main=-967549 worker=222 FAIL: counts
same loop, NO thread     3 runs: main=300000 CTL OK   (3 of 3)

TWO THINGS THE ORIGINAL MEASUREMENT DID NOT RECORD.

1. The CREATOR is broken too, not just the new thread. main=-967549 is the -1000000 sentinel the fixture writes when the try body does NOT raise, plus later increments -- so the main thread's own try/except stopped working, in a process where it had been perfect moments before. The no-thread control is the same loop in the same binary and is exactly 300000, three times. Creating the foreign thread is what breaks the thread that created it. That follows from the mechanism (they SHARE one chain head) but it was never stated, and "a foreign thread is broken" reads as though the rest of the program is fine.

2. PXX'S OWN THREADS ARE NOT AFFECTED. Measured, not assumed:

NilPy `import threading`, Thread(target=worker), 50k try/except per row
  before=50000  during=50000  worker=50000  after=50000   3 runs of 3
CPython oracle: identical

The boundary is PxxPthreadStart (palthread.pas), the start routine glibc calls for a PAL-made thread: it installs pxx's own gs block before running the entry, doing for a pthread-made thread exactly what the clone stub's child leg does for a cloned one. A thread created THROUGH the PAL therefore gets a zeroed chain head and is correct. A thread created by USER CODE declaring its own external 'libpthread.so.0' never runs that routine and never gets a block -- which is precisely this bug, and precisely the fixture's shape.

So the population is user-written raw-pthread code and threaded C libraries (DOSBox, SDL), NOT threading, TThread, or anything using the PAL. Worth stating plainly because the summary's "every thread kind DOSBox, SDL and every threaded C library create" is correct and is easily read as "every thread", which would make it a much bigger and much more urgent bug than it is.

This does NOT lower the prio: the named consumers are real targets. It bounds what has to be re-verified when the decide- is answered.

Measured 2026-09-02 at efc33772a.

The measurement, with its control

test/test_foreign_thread_exception_chain.pas — main thread and one pthread_created thread, each running try Boom; except Inc(n) end in a loop, counting.

foreign thread, 300k each   run1..3: Unhandled exception
same work, ONE thread, 600k run1..3: main=300000 worker=300000 FOREIGNEXC OK
foreign thread, 2k each     run1: OK   run2: (no output at all)   run3: OK

The single-threaded row is the control and it is drawn from the same binary and the same code path — the worker function is called directly instead of through pthread_create. So this is the thread, not the loop.

Why the existing fix does not cover it

defs.inc's note at TLS_SLOT_EXC_TOP states the mechanism exactly:

A fresh thread gets a ZEROED block from the clone stub, i.e. an empty chain, which is exactly right and is the other half of the fix.

A foreign thread never runs the clone stub. clone does not reset gs, so it starts life pointing at its creator's block with the creator's chain head in it. Thread A's try then links onto thread B's frame and a raise longjmps into a frame that may already be dead — which is the same sentence that ticket used to describe the bug it fixed.

This is the concrete, crashing instance of [[bug-a-a-foreign-thread-shares-the-main-thread-s-heap-magazine]]. That ticket is right that the general question is a design decision; this one is not a design question, it is a program that fails.

What is already in place, and it is more than the parent ticket says

The parent lists "detect and install lazily" and dismisses the detection half: "the block's self-pointer at slot 0 does not work ... a gettid comparison needs a syscall or a cached value with the same bootstrap problem."

That was solved and shipped. feature-a-io-lock-owner-from-tls-not-gettid established the discriminator inheritance cannot fake — the reader's own rsp against the bounds the block's owner recorded — and it is live in ir_codegen.inc:1099-1118, guarding the I/O lock's cached tid. The parent's option analysis is stale about its own hardest sub-problem.

So the missing half is not detection. It is where a foreign thread's block comes from and what bounds go in it, which is the decision this is blocked on.