← board

Test binaries hardcode /tmp paths that no Makefile sweep can reach

The gap, measured

A Makefile sweep can only reach paths the recipe names. A path the compiled test writes at runtime is invisible to it — and testmgr's privatization does not apply either, because testmgr rewrites the recipe text it executes, not the string constants inside a binary it runs.

Measured at the sweep's landing commit:

count
distinct /tmp paths hardcoded in compiled sources under test/, lib/, examples/ 63
of those, also named in the Makefile → pinned literal by the sweep, correctly 3
written only at runtime by the binary — the residue this ticket is about 60
source files involved 37

The three pinned ones (/tmp/test_nilpy_sqlite_crud.db, /tmp/pxx_lua_input.lua, /tmp/httpdemo) stay literal on purpose: recipe and source must agree on the name. That is the pin rule working — but they are still collisions, just visible ones.

The race is live, including under testmgr

Worth stating plainly, because the sweep's green gate invites the opposite conclusion: two concurrent runs still share these files even under testmgr, the one runner everyone assumes is isolated. Two runs opening /tmp/test_nilpy_sqlite_crud.db at once is a data race on a database file, not a flaky string compare — and test/csqlite_parity_selfcompiled.c and test/csqlite_thread_test.c both write plain /tmp/x, so they collide with each other and with any ad-hoc scratch file a human or agent leaves there. /tmp/x is the single most likely name in the repo to be clobbered from outside.

The files

Each needs a decision, which is why this is not a scripted sweep: some paths are load-bearing (a recipe or a second process must agree on the name, a fixture the test asserts about) and some are pure scratch.

source hardcoded paths
test/cdup.c /tmp/pxx_dup_probe.txt
test/cfile_stdio_b87.c /tmp/pxx_crtl_file_stdio_b87.txt
test/cfileops.c /tmp/pxx_fops_probe, /tmp/pxx_fops_probe/hard.txt, /tmp/pxx_fops_probe/orig.txt, /tmp/pxx_fops_probe/sym.txt, /tmp/pxx_no_such_dir_zz
test/cjson/runner.c /tmp/pxx_cjson_input.json
test/cposix_io.c /tmp/pxx_cposix_io.txt
test/crtl_lfs64_aliases_b234.c /tmp/pxx_crtl_lfs64_b234.tmp
test/crtl_libc_oracle.c /tmp/crtl_stdio_probe.txt
test/crtl_posix_io_leaf_b238.c /tmp/pxx_crtl_posix_io_b238.dir, /tmp/pxx_crtl_posix_io_b238.tmp, /tmp/pxx_no_such_file_b238
test/crtl_stat_errno_enoent_b235.c /tmp/pxx_b235_no_such_dir_zzz/nope.file
test/csqlite_file_probe.c /tmp/pxx_sqlite_file_probe.db
test/csqlite_parity_selfcompiled.c /tmp/x
test/csqlite_thread_test.c /tmp/x
test/cstat_fields.c /tmp/pxx_stat_probe, /tmp/pxx_stat_probe/f.txt, /tmp/pxx_stat_probe/h.txt, /tmp/pxx_stat_probe/sub
test/lib_directory.pas /tmp/pxx_dir_suite, /tmp/pxx_dir_suite/alpha.txt, /tmp/pxx_dir_suite/child
test/lib_fpc_surface_2026_08.pas /tmp/lib_fpcsurf_a.txt, /tmp/lib_fpcsurf_b.txt, /tmp/lib_fpcsurf_c.txt, /tmp/lib_fpcsurf_d.txt
test/lib_platform.pas /tmp/pxx_pal_platform.txt, /tmp/pxx_pal_platform_dir, /tmp/pxx_pal_platform_renamed.txt
test/lib_platform_esp.pas /tmp/no-host-fallback, /tmp/no-host-fallback-2, /tmp/no-host-fallback-dir
test/lib_textfile.pas /tmp/pxx_lib_textfile.txt
test/lib_textreadchar.pas /tmp/lib_textreadchar_1.txt, /tmp/lib_textreadchar_2.txt, /tmp/lib_textreadchar_3.txt
test/lua/files.lua /tmp/pxx_lua_file_api.txt
test/quickjs/runner.c /tmp/qjs_runner
test/test_cross_sysopen_family.pas /tmp/frankonpiler_sysopen_family.tmp
test/test_nilpy_file_close_readlines.npy /tmp/pxx_test_close_readlines.txt
test/test_nilpy_file_read_follows_the_mode.npy /tmp/pxx_nilpy_read_mode.txt
test/test_nilpy_file_write_text.npy /tmp/pxx_test_write_append.txt, /tmp/pxx_test_write_text.txt, /tmp/pxx_test_write_text2.txt, /tmp/pxx_test_write_text3.txt, /tmp/pxx_test_write_text5.txt
test/test_nilpy_json_module.npy /tmp/pxx_test_json_module.json
test/test_nilpy_len_of_a_file_read.npy /tmp/test_nilpy_len_read.txt
test/test_nilpy_open_one_class_every_mode.npy /tmp/pxx_test_open_one_class.txt
test/test_nilpy_os_path_more.npy /tmp/pxx_osp_test.txt
test/test_nilpy_typeerror_is_catchable.npy /tmp/pxx_absent_file_zz.txt
test/test_nilpy_with_name_reuse.npy /tmp/pxx_test_with_name_reuse.txt
test/test_nilpy_with_protocol.npy /tmp/test_nilpy_with_protocol.txt
test/test_nilpy_write_overload_by_arg_type.npy /tmp/pxx_test_write_overload.txt
test/test_sqlite_crud.pas /tmp/test_sqlite_crud26.db
test/test_textfile.pas /tmp/test_textfile_data26.txt
test/test_textfile_in_unit.pas /tmp/pxx_textfile_unit_test.txt
test/test_writeln_text_char.pas /tmp/test_writeln_text_char.txt

(examples/net/httpdemo.pas also hardcodes /tmp/httpdemo, but that one is in the pinned set — recipe and source agree on it deliberately.)

Suggested approach

Read the directory from the environment rather than inventing a per-test convention: the sweep already exports TESTTMP, so a test can honour it with a getenv and fall back to /tmp. That keeps the default byte-identical, needs no recipe change, and makes make <suite> TESTTMP=$(mktemp -d) isolate the runtime half exactly as it now isolates the recipe half — one mechanism, not two, which is the whole point.

Where a path really is load-bearing, pass it from the recipe as an argument or an env var instead of duplicating the literal in two places. Duplication is what made these unsweepable to begin with.

A caveat worth honouring: some of these paths are load-bearing for what the test assertstest/lib_platform_esp.pas's /tmp/no-host-fallback names are there to prove the ESP PAL refuses them, and crtl_stat_errno_enoent_b235 depends on its path NOT existing. Those want reading before rewriting.

Ownership

Track T owns the shape and testmgr's pin rule. Individual sources belong to their lane — the C fixtures are Track C, the .npy ones N, lib_*/examples B. Per CLAUDE.md, T owns the tool, never the bug: if honouring TESTTMP in a given test turns up a compiler or RTL gap, file it into the owning lane rather than working around it here.

Gate

No /tmp path hardcoded in a compiled test source outside the agreed pinned set; a suite run with a non-default TESTTMP leaves /tmp untouched; two concurrent runs with different TESTTMP values share no file.

2026-08-19, Track T — the guard is built; here is the per-lane split

T's half is done: tools/testmgr_hardcoded_tmp_devtest.py, in make tools-devtest (limited + full), so the class is detectable instead of rediscovered. The per-source work stays with the owning lanes, as this ticket's Ownership section says and as T's boundary requires.

It is a RATCHET: the 61 existing pairs sit in KNOWN and the guard is green on them; anything not listed FAILS. Verified by adding a probe source with a hardcoded path — it failed, naming the file and the path — and green again once removed. When a lane fixes a file, the guard prints … no longer hardcodes … — remove it from KNOWN, so the list tightens rather than drifting into a permanent allowlist.

The ratchet is the right shape because the class is still GROWING. This ticket recorded 37 files at its landing commit; there are 40 today. /tmp/x went away and /tmp/b arrived (test/test_nilpy_oserror_class_and_message.npy), along with test_nilpy_bare_genexpr_arguments.npy, test_nilpy_file_writelines.npy and test_nilpy_sqlite_crud.npy. Fixing 60 while the 61st is being written is not progress.

Two corrections to the counts above

Both come from scanning string literals rather than raw text, which is the right measure for "a path the binary writes at runtime":

A path a binary never writes cannot collide, so the residue is 61 pairs across 39 files, not 60 across 37, and it is differently distributed than the table suggests.

The split, ready to route

lane files paths what they are
C 12 21 the .c fixtures plus test/lua/files.lua
N 14 18 the .npy tests, mostly one path each
B 6 17 test/lib_*.pas — directory, textfile, platform, fpc-surface
P 5 5 the remaining test/*.pas — sysopen, sqlite_crud, textfile, writeln

N is the largest by file count and the cheapest per file (14 files, 18 paths, almost all a single scratch file honouring $TESTTMP with a getenv). C is the largest by path count and holds both load-bearing cases. B and P are small enough to be one sitting each.

Load-bearing — these are NOT sweepable, and the guard says so in its header

Recorded here because a future sweeper will otherwise "fix" both and break them silently, and until now the only place this was written down was a chat message:

test/lib_directory.pas, cfileops.c and cstat_fields.c create directory TREES rather than single files, so they want reading before rewriting too — they are not one-line getenv changes.

What T is not doing

Not editing the 39 sources (three other lanes own them), and not filing the four lane tickets — routing is the coordinator's. This section is the proposal.

Routing, decided by the coordinator 2026-08-19

lane files state
C 12 ROUTED — active lane under the current mandate
P 5 ROUTED — same
N 14 HELD on the Track N deferral. Not unowned and not an oversight: the user has deferred Track N entirely, and A/P/C outranks everything N including N bugs. These are the cheapest of the four groups (14 files, 18 paths, almost all one scratch file honouring $TESTTMP via getenv), so they will close quickly whenever N resumes
B 6 PARKED — no worker. frank2 is on A/P/C triage. Not deferred by policy, just unstaffed

Recorded per lane because an unrouted item with no reason reads as an oversight, and the next reader would otherwise spend the time re-deriving why 20 of the 37 files have nobody on them.

The ratchet means none of this is urgent: nothing new can be added while the groups wait, which is the property a cleanup would not have had.

Deprioritised 2026-09-02 — the Track T tooling backlog was cut as a pile

This ticket is not being called wrong. It was moved as part of a pile, not judged individually, and nothing here disputes its finding.

Owner decision. 73 of the 74 open track: T tickets were filed between 2026-08-31 and 2026-09-02, 58 on one day. The pile was too large to work through and returned almost nothing, and a ticket nobody will fix does not sit neutrally — it stays in the ranker forever at zero value, which is the argument CLAUDE.md already makes for a terminal folder over a low prio.

Four were kept in the ranker on a purely structural test — an active umbrella or a hard blocked-by: edge from live work: umbrella-one-full-tier-run-with-no-red-tier, feature-t-freebsd-image-and-runner, and the two regression-test-core-* reds that block the umbrella.

Kept, not deleted, for two reasons: so the finding is not rediscovered and refiled from scratch by the next agent who trips over it, and so it can be pulled back if what it touches becomes load-bearing.

To revive it: move it to the owning lane's backlog, set status: backlog, and say in the ticket WHAT CHANGED to make it matter now. Restoring it because it reads well is how the pile comes back.