← board

Makefile: parameterize hardcoded /tmp test paths ($(TESTTMP)) — concurrent gates corrupt each other

State

Makefile test recipes hardcode 6755 occurrences of literal /tmp/... across 3171 distinct paths (recounted 2026-08-13; the old "3037/1700" figures predate a year of growth). Any two concurrent test runs on one box race on all of them.

testmgr already contains the runtime fix: Job.script() rewrites literal /tmp/ to a private per-run scratch dir when executing job scripts, so concurrent testmgr runs (dev gate + watcher, or two watchers) are isolated by construction.

NOT covered: plain make. And that is not only the human case any more — tools/gate.sh shells out to raw make for its suites (gate.sh quick runs make test-nilpy), so the gate every track is told to use inherits the race testmgr avoids. The failure is a false RED, or worse a false GREEN when the clobbering run happened to write the expected bytes; it is timing-dependent, so it reads as flakiness rather than as a collision.

The self-host BUILD half of this is already done — [[bug-t-selfhost-build-uses-fixed-tmp-paths-colliding-across-clones]] landed PXX_TMP (per-invocation, pid-keyed, exported). This ticket is the test-OUTPUT half, and it follows the same shape one variable down.

Wanted

TESTTMP ?= /tmp at the top, recipes use $(TESTTMP)/.... Behaviour-identical by default; then make test TESTTMP=$(mktemp -d) makes manual and gate runs safe too.

Use ?= /tmp, NOT a per-invocation mktemp default

The now-consolidated Track T ticket proposed PXX_TMP ?= $(shell mktemp -d /tmp/pxx-run-XXXXXX) — a per-invocation default, mirroring what PXX_TMP does. That shape breaks every testmgr job, and the reason is not obvious, so it is recorded here rather than rediscovered:

testmgr privatizes a job's paths by prefix substitution/tmp/foo becomes /tmp/testmgr-scratch-<pid>/foo (TMP_RE.sub in Job.script()). Today every recipe path is flat under /tmp, so only the scratch root itself needs creating. A nested default makes recipes say /tmp/pxx-run-ab12/foo, which testmgr rewrites to <scratch>/pxx-run-ab12/foo — a subdirectory nothing creates. Every job that writes an output would fail.

So the default stays flat and isolation becomes the caller's to ask for. That is also the better separation: under testmgr, isolation already exists and needs no help; under raw make, gate.sh passes a fresh dir. Neither side has to know about the other.

The sweep — scripted, and mechanically verified

Track T ran this end to end on 2026-08-13 and then reverted it (out of lane). The numbers below are measured, not estimated.

Insert after the export PXX_TMP block:

# Per-invocation scratch root for the TEST recipes' OUTPUTS (see PXX_TMP above,
# which covers the self-host BUILD's intermediates).
#
# Default is plain /tmp, i.e. behaviour-identical, and deliberately NOT a
# per-invocation mktemp like PXX_TMP: tools/testmgr.py privatizes a job's /tmp
# paths by PREFIX SUBSTITUTION (/tmp/foo -> /tmp/testmgr-scratch-<pid>/foo), so
# a nested default would expand to <scratch>/pxx-test-<pid>/foo -- a directory
# nothing creates, breaking every job. Isolation is the CALLER's to ask for:
#   make test-nilpy TESTTMP=$$(mktemp -d)
TESTTMP ?= /tmp
$(shell mkdir -p $(TESTTMP))
export TESTTMP

then run:

#!/usr/bin/env python3
"""Rewrite the Makefile's literal /tmp paths to $(TESTTMP)/...  Skips comment
lines (readability; they stay true at the default), the PXX_TMP/TESTTMP
definitions, and paths a COMPILED source hardcodes (testmgr's pinned rule)."""
import re, os, sys
TMP_RE = re.compile(r"/tmp(?![\w.-])(?:/[A-Za-z0-9_.+-]+)*")
SRC_EXT = (".pas", ".npy", ".c", ".h", ".inc", ".f", ".lua", ".js", ".py",
           ".pp", ".s", ".asm")
pinned = set()
for root in ("test", "lib", "examples"):
    for dp, dn, fn in os.walk(root):
        for f in fn:
            if not f.endswith(SRC_EXT):
                continue
            try:
                t = open(os.path.join(dp, f), errors="replace").read()
            except OSError:
                continue
            pinned.update(m for m in TMP_RE.findall(t) if m != "/tmp")
out, n_sub, n_pin = [], 0, 0
for ln in open("Makefile", errors="replace").read().split("\n"):
    if ln.strip().startswith("#") or "PXX_TMP ?=" in ln or "TESTTMP ?=" in ln:
        out.append(ln); continue
    def rep(m):
        global n_sub, n_pin
        tok = m.group(0)
        if tok in pinned:
            n_pin += 1; return tok
        n_sub += 1; return "$(TESTTMP)" + tok[len("/tmp"):]
    out.append(TMP_RE.sub(rep, ln))
open("Makefile", "w").write("\n".join(out))
sys.stderr.write("rewrote %d, left %d pinned\n" % (n_sub, n_pin))

Measured result: rewrote 6755, left 4 pinned.

Why comment lines are skipped

Readability of a 788 KB diff, and they stay accurate at the default. The one non-comment line that must be excluded by name is the PXX_TMP ?= definition itself.

The pinned set is far smaller than testmgr's docstring implies

Job.script() cites test_c_lazycasing.pas's external '/tmp/liblazycasing.so' as the reason pins exist. That is stale — the source now says external 'liblazycasing.so' (soname only, found via LD_LIBRARY_PATH), and the same for test_c_argspill.pas / libspill.so. A Track C ticket retired them, as testmgr's own comment predicted it would.

Measured today: 63 distinct /tmp paths are hardcoded in compiled sources, and exactly 3 of them are also named in the Makefile. After the sweep the only literal /tmp path surviving in make -n test-nilpy is /tmp/test_nilpy_sqlite_crud.db — which is correct, and is a visible, self-documenting artefact of the pin rule.

Verification protocol — total, not sampled

This is what makes the change safe to land as ONE commit rather than the per-suite dribble the consolidated ticket asked for. Bisectability was the reason for splitting it; a total expansion diff is strictly stronger, because a mistake cannot hide in an unsampled target.

# capture `make -n` for EVERY target, with PXX_TMP pinned so the per-pid
# default does not make two captures differ for an unrelated reason
capture() {
  mkdir -p "$1"
  for t in $(grep -oE '^[a-zA-Z0-9_.-]+:' Makefile | tr -d ':' | sort -u); do
    make -n --no-print-directory PXX_TMP=/tmp/PINNED "$t" >"$1/$t.txt" 2>&1
  done
}
capture /tmp/before      # before the sweep
capture /tmp/after       # after
diff -r /tmp/before /tmp/after && echo "behaviour-identical at default"

Measured 2026-08-13: 90 targets, 37825 lines, diff clean. Since testmgr builds its job list from make -n (make_dry_run), an identical expansion also proves the sweep is completely transparent to testmgr.

Behavioural half, with a non-default TESTTMP:

$ D=$(mktemp -d); make test-smoke TESTTMP=$D   ->  rc=0, 37 files under $D

test-smoke was chosen because it runs the self-host fixedpoint chain (pascal26-self -> -next -> -fixedpoint -> cmp), which is the sharpest collision case in the repo and the one that produced the original 2026-07-08 incident.

Residual: the sweep is necessary but NOT sufficient

60 of those 63 source-hardcoded paths are written by the test BINARY at runtime, not by the recipe, across 40 source files — e.g. test/test_nilpy_sqlite_crud.npy:7 opens /tmp/test_nilpy_sqlite_crud.db. No Makefile sweep can reach them, and testmgr deliberately does not privatize them (pinned_tmp_paths), so two concurrent runs still share those files even under testmgr. That is a real, currently-live collision mechanism, and it is the one this ticket's Gate below would otherwise claim to have closed.

Do not widen this ticket to cover them — it is a separate, smaller job (40 files, and each needs a decision about whether the path is load-bearing). File it when someone picks this up. Just do not read a green Gate here as "no shared temp files remain".

Why this is Track A, and why Track T did not land it

The work is entirely in the Makefile — shared ground under A's file-ownership and A's gate. CLAUDE.md scopes Track T to tools/testmgr.py, tools/twatch*, tools/fuzz.sh, tools/pasmith* and tstate/**, "and nothing else", and a 6755-site sweep of every track's recipes needs the sole-A confirmation T cannot give itself. Master was also RED at the time (an unrelated Track N regression), which is the wrong moment to land a change that touches every suite.

So T did the mechanical de-risking — the script, the pinned-set measurement, the total verification, the mktemp-default landmine — and handed it over. Whoever takes it needs sole-A and a green master; the sweep itself is minutes.

Gate

make test + self-host byte-identity green with TESTTMP unset AND with TESTTMP set to a scratch dir; testmgr full tier green. Plus the expansion diff above, which is cheap and catches more than the suites do.

Note the Gate that CANNOT be met here, per the residual section: "two concurrent runs share no output file" is false until the 60 source-hardcoded runtime paths are dealt with separately.

Hard constraint: do NOT set TESTTMP for a testmgr run without teaching it

Four regexes in tools/testmgr.py hardcode the literal /tmp prefix as it appears in make -n output, and all four are load-bearing:

line regex what breaks if it goes blind
TMP_RE /tmp(?![\w.-])(?:/…)* no privatization — concurrent testmgr runs collide again
tmp_re /tmp/[A-Za-z0-9_./+-]+ producer/consumer jobs stop merging (--job repro runs with a scratch where the artifact never existed)
so_prod_re -o\s+/tmp/\S+\.so\b the .so producer/consumer edge, which is invisible to a filename scan
loader_dir_re LD_LIBRARY_PATH=/tmp(?!…) same edge, consumer side — this is how test-core#555/#556 went red on 2026-07-12

They are safe today because TESTTMP ?= /tmp keeps the expansion literal — which is a second, independent reason the default must not become a per-invocation mktemp. But the coupling is invisible from either side, so if a later change has gate.sh or testmgr pass TESTTMP=<scratch> into make_dry_run(), all four go blind at once and fail silently — no error, just a run that has quietly lost its isolation and its job ordering.

Noted in the code at both sites as of 2026-08-13. Teach the regexes the value before setting it.


Done — 2026-08-14

Landed exactly as Track T de-risked it, in one commit, with the total verification rather than a sample.

Numbers, re-measured at landing

ticket (2026-08-13) landed
sites rewritten 6755 6902
left pinned 4 4
targets captured for the expansion diff 90 90
make -n lines compared 37825 38884
expansion diff clean clean

The site count grew by ~150 in a day, which is the ticket's own argument for doing this now rather than later.

The sweep also had to be re-applied on top of a concurrent Track B push that rewrote several gcc-oracle recipes mid-rebase. Resolving that hunk by hunk would have been error-prone across 6902 sites; because the change is purely mechanical it was instead regenerated — take upstream's Makefile whole, re-insert the block, re-run the script, and re-capture the expansion diff against a fresh baseline. A regenerable change should be regenerated, not merged. The numbers above are from that second run.

The four pinned occurrences resolve to three distinct paths — /tmp/test_nilpy_sqlite_crud.db, /tmp/pxx_lua_input.lua, /tmp/httpdemo — each hardcoded in a compiled source that the recipe must agree with. The ticket predicted the pinned set would be far smaller than testmgr's stale docstring implies, and it is: the liblazycasing.so / libspill.so cases it called out as retired are indeed gone.

Verification as run

Both halves, as specified:

One practical note for whoever repeats this: the recipe lines must be run each in its own shell, the way make runs them. Flattening a target's make -n output into a single script exits early and silently reports success, because the self-host prerequisite chain ends in exit 0 on convergence — the remaining 60-odd lines never run, and a naive rc=0 reads as a pass. That is a verification that proves nothing, and it looked exactly like a real one.

gate.sh quick GREEN (self-host fixedpoint + testmgr quick). The ticket's Gate: line naming make test and the full tier is superseded by CLAUDE.md's per-fix loop (user, 2026-08-01, decide-gate-line-convention); the total expansion diff is both cheaper and strictly stronger than the suites here, since a mistake cannot hide in an unsampled target.

Residual — filed, not silently inherited

The ticket's own warning stands and is now a ticket: [[chore-t-test-binaries-hardcode-unsweepable-tmp-paths]]. Re-measured exactly: 63 distinct /tmp paths hardcoded in compiled sources, 3 of them pinned because the Makefile names them too, leaving 60 written only at runtime by the test binary across 37 source files. No Makefile sweep can reach those, and testmgr does not privatize them either — it rewrites recipe text, not string constants inside a binary — so two concurrent runs still share those files even under testmgr.

Worth singling out: test/csqlite_parity_selfcompiled.c and test/csqlite_thread_test.c both write plain /tmp/x, which collides with each other and with any scratch file anyone leaves at that name.

So a green gate here means the recipe half is closed, and nothing more.

Log