fix(test): the multibyte witness had the wrong axis, and CI proved it

CI went red on three Test jobs with the helper's own panic. That is the
loud failure working as designed --- and the design was wrong.

`${#x}` counting characters is a property of the SHELL first and the
locale second. `bash` counts characters under a UTF-8 locale; `dash`
counts bytes under EVERY locale, having no multibyte handling at all.
`/bin/sh` is `bash` on Arch and `dash` on the Ubuntu runners, so
probing `/bin/sh` and naming a locale established nothing there: no
pair could qualify, and the row could not run.

The helper now probes `(shell, locale)` pairs and the row invokes the
gate THROUGH the qualifying shell. `/bin/sh` is tried first so the real
interpreter is used wherever it qualifies. This is not a contrivance to
get green: `#!/bin/sh` resolves to `bash` on Arch and on macOS, which
is precisely where a `${#VAR}` guard would miscount, and it is the
configuration the guard exists for.

Renumbered, because `M-G-8` was taken. Round 3 assigned it to the
canonical-traversal mutation and the ledger never recorded it, so the
locale exercise reusing the ID was a collision. Canonical `M-G-8` is
restored to the ledger; the locale legs are `M-G-9a-c`. Nine total.

  9a  mutant gate, probed pair -> row fails, boundary row still passes.
      Re-run with /bin/sh EXCLUDED, covering the dash/CI fallback
      path -> still fails.
  9b  SAME mutant gate, pair forced byte-counting -> row passes.
      The defect reproduced rather than argued.
  9c  no pair qualifies -> panic naming shells and locales tried

Record corrections review asked for:

- framing said three rounds and revisions 6a-6c; history is rounds 1-4
  plus this follow-up, and each round is now named for what it fixed
- framing SS2a claimed `${#var}` counts characters under UTF-8 with no
  qualifier --- the same error as the helper's. It now states the shell
  dependence and why the guard measures bytes explicitly.
- the helper's prose said every candidate comes from `locale -a` while
  the code also tried two hardcoded spellings; the doc comment now
  describes what the code does

Gates: all nine green under `env -u TMPDIR`, log 20260813T183646Z.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-13 20:41:18 +02:00
parent 3a7e3790a1
commit da74ae645b
No known key found for this signature in database
3 changed files with 101 additions and 68 deletions

View File

@ -328,29 +328,33 @@ from #171 and #215.
is why that row covers a `Cargo.toml` **directory** as well as both
`.git` shapes. `M-G-6` counts characters → the multibyte row.
`M-G-7` moves the trap back after the guards → the
rejection-cleanup row. Propagation is observed in a **spawned
child**, because the gate exporting a variable would only prove the
gate can export a variable.
- **`M-G-8` is the one that proves `M-G-6` is not vacuous**, and it
rejection-cleanup row. `M-G-8` restores the old
`for _anc in $(...)` loop → the canonical-walk row, which is what
proves the traversal neither word-splits a space-bearing root nor
misses a marker visible only after `pwd -P`. Propagation is observed
in a **spawned child**, because the gate exporting a variable would
only prove the gate can export a variable.
- **`M-G-9` is the one that proves `M-G-6` is not vacuous**, and it
needs three legs because the hazard is in the *environment*, not the
code. The multibyte row originally set `LC_ALL=C.UTF-8` and assumed
it took: locale names beyond `C` and `POSIX` are
implementation-defined, so on a machine lacking that locale the shell
falls back to **byte** semantics — and then the character-counting
mutant counts bytes too, agrees with the fix, and the row passes
while proving nothing.
- **8a** — mutant gate, locale chosen by the probe → the row
**fails**, and the exact-boundary row still passes.
- **8b** — *same mutant gate*, locale forced to `C` → the row
**passes**. This is the defect itself, reproduced rather than
argued: the only difference between a real witness and a vacuous
one is whether the shell counts characters.
- **8c** — no candidate can qualify → the helper **panics** naming
what it tried. A skip here would be indistinguishable from a pass.
The locale is therefore selected by **behaviour**: candidates come
from `locale -a`, and each is probed through the same `/bin/sh` the
gate runs under, asking `${#x}` on a two-byte character and requiring
`1`.
code. **Nine mutations in total.**
- **9a** — mutant gate, probed pair → the row **fails**, and the
exact-boundary row still passes. Re-run with `/bin/sh` excluded, so
the CI fallback path is covered too: still fails.
- **9b** — *same mutant gate*, pair forced to byte-counting → the row
**passes**. The defect itself, reproduced rather than argued.
- **9c** — no pair can qualify → the helper **panics** naming what it
tried. A skip would be indistinguishable from a pass.
- **The multibyte row's axis was wrong, and CI is what proved it.** The
row set `LC_ALL=C.UTF-8` and assumed a character count followed.
`${#x}` counting characters is a property of the **shell** first:
`bash` counts characters under a UTF-8 locale, **`dash` counts bytes
under every locale**. `/bin/sh` is `bash` here and `dash` on the
Ubuntu runners, so the row panicked on CI — the loud failure working
as designed, but on a machine where the distinction is unobservable.
The helper now probes `(shell, locale)` pairs and invokes the gate
**through** the qualifying shell. That is not a contrivance: `#!/bin/sh`
resolves to `bash` on Arch **and on macOS**, which is exactly where a
`${#VAR}` guard would miscount.
- **Proved against the live hazard:** `/tmp/.git` is still present on
this machine, and the tests it reddened now pass with **no override**.
- **Gates:** `./scripts/gate --acceptance gate_script_acceptance`, run

View File

@ -7,15 +7,18 @@ gate isolates", and `TMPDIR` was simply missing from that list — which
is how a stray `/tmp/.git` came to redden a gate run on an unrelated
lane.
**Approved after three review rounds, all of which turned on evidence
rather than design.** Revisions 6a–6c tightened the socket budget to
the Darwin floor, replaced an existence-only ancestor check with one
that honours marker types, made the traversal canonical, moved the
guard rows onto the exact boundary, and — last — required the
byte-versus-character row to *establish* its locale precondition
instead of naming one. Each correction was a witness asserting
something adjacent to the contract while appearing to assert the
contract itself.
**Approved after four review rounds plus a locale follow-up, all of
which turned on evidence rather than design.** Round 1 corrected a
propagation witness that proved only that the variable was used, and
two wrong guard reserves. Round 2 tightened the socket budget to the
Darwin floor and replaced an existence-only ancestor check with one
that honours marker types. Round 3 made the traversal canonical and
gave the length guard its first real witnesses. Round 4 moved those
rows onto the exact boundary, covered both managed areas on cleanup,
and withdrew an unsupported causal claim. The follow-up required the
byte-versus-character row to *establish* its precondition rather than
name one. Each correction was a witness asserting something adjacent
to the contract while appearing to assert the contract itself.
**Previously, revision 5. Approved at revision 4 and IMPLEMENTED; revision
5 records two safety defects review found in the implementation.**
@ -477,8 +480,14 @@ Four decisions inside that, each of which had a cheaper wrong answer:
the symptom appear deep in a suite as a limit with no cause:
- a **byte-counted** length check reserving the measured maximum
suffix (`/.tmpXXXXXX/directory-target.sock`, 33 bytes) plus
headroom — byte-counted because `${#var}` counts *characters*
under UTF-8 while `sun_path` is byte-limited;
headroom — byte-counted because `sun_path` is byte-limited while
`${#var}` counts *characters* in a shell that handles multibyte.
**Which shell runs the script decides this**: `bash` counts
characters under a UTF-8 locale, `dash` counts bytes under every
locale, and `#!/bin/sh` is `bash` on Arch and macOS but `dash` on
Debian and Ubuntu. The count must not depend on that, so the guard
measures bytes explicitly (`printf | LC_ALL=C wc -c`) rather than
relying on the interpreter it happens to get;
- an **ancestor-marker check**, because **a managed root is not
inherently marker-free**: a `.git` in `$HOME`, a marker above
`$HOME/build`, or a contaminated `PMACS_GATE_TARGET_ROOT` rebuilds

View File

@ -834,14 +834,21 @@ fn the_socket_path_guard_counts_bytes_not_characters() {
bytes (must fail), path {path}"
);
// **Chosen by BEHAVIOUR, not by name.** Locale names beyond `C` and
// `POSIX` are implementation-defined and an unrecognized value has
// unspecified behaviour, so setting `LC_ALL=C.UTF-8` and hoping is
// not a precondition — where that locale is absent the shell would
// fall back to byte semantics and the character-counting mutant
// would pass, silently.
let locale = char_counting_locale();
let out = std::process::Command::new(gate())
// **Chosen by BEHAVIOUR, not by name**, and the interpreter is part
// of the choice: `${#x}` counting characters is a property of the
// SHELL first and the locale second. `bash` counts characters under
// a UTF-8 locale; `dash` counts bytes under every locale. Naming a
// locale and running `/bin/sh` therefore proves nothing on its own —
// where `/bin/sh` is `dash`, the character-counting mutant measures
// bytes too, agrees with the fix, and this row passes vacuously.
//
// The gate is invoked THROUGH that shell rather than by its
// shebang, because the configuration being pinned is a real one:
// `#!/bin/sh` resolves to `bash` on Arch and on macOS, which is
// exactly where a `${#VAR}` guard would miscount.
let (shell, locale) = char_counting_shell();
let out = std::process::Command::new(&shell)
.arg(gate())
.arg("--self-test")
.current_dir(repo_root())
.env("PMACS_GATE_TARGET_ROOT", root.path())
@ -860,27 +867,35 @@ fn the_socket_path_guard_counts_bytes_not_characters() {
);
}
/// A locale under which **`/bin/sh` counts CHARACTERS** — verified by
/// asking that very shell, not by trusting a name.
/// A `(shell, locale)` pair under which **`${#x}` counts CHARACTERS** —
/// established by asking that very shell, never by trusting a name.
///
/// `${#x}` on a two-byte character answers `1` under a working UTF-8
/// locale and `2` under `C`. That difference is the entire subject of
/// the row this serves, so the row must establish it as a precondition
/// rather than assume it: otherwise, on a machine without the named
/// locale, the shell counts bytes, the mutant agrees with the fix, and
/// the test passes while proving nothing.
/// **Both axes matter, and the shell matters more.** `${#x}` on a
/// two-byte character answers `1` under `bash` with a UTF-8 locale and
/// `2` under `bash` with `C` — but `dash` answers `2` under *every*
/// locale, because it has no multibyte handling at all. So naming a
/// locale and invoking `/bin/sh` establishes nothing: where `/bin/sh`
/// is `dash` (Debian and Ubuntu, including CI) the character-counting
/// mutant measures bytes too, agrees with the fix, and the row this
/// serves passes while proving nothing.
///
/// Candidates come from `locale -a`, so this reflects what is actually
/// installed. **Fails loudly when none qualifies** — a skip here would
/// be indistinguishable from a pass.
fn char_counting_locale() -> String {
/// `/bin/sh` is tried first, so the real interpreter is used wherever
/// it qualifies — as it does on Arch and macOS, which is precisely
/// where a `${#VAR}` guard would miscount.
///
/// Locale candidates are the two conventional `C.UTF-8` spellings,
/// which are commonly usable without appearing in `locale -a`,
/// followed by every UTF-8 entry `locale -a` does report. **Fails
/// loudly when no pair qualifies** — a skip here would be
/// indistinguishable from a pass.
fn char_counting_shell() -> (String, String) {
let installed = std::process::Command::new("locale")
.arg("-a")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
let candidates: Vec<String> = ["C.UTF-8".to_owned(), "C.utf8".to_owned()]
let mut locales: Vec<String> = ["C.UTF-8".to_owned(), "C.utf8".to_owned()]
.into_iter()
.chain(
installed
@ -889,24 +904,29 @@ fn char_counting_locale() -> String {
.map(str::to_owned),
)
.collect();
locales.dedup();
for cand in &candidates {
let probe = std::process::Command::new("/bin/sh")
.arg("-c")
.arg("x=é; echo ${#x}")
.env("LC_ALL", cand)
.env("LANG", cand)
.output();
if let Ok(out) = probe
&& String::from_utf8_lossy(&out.stdout).trim() == "1"
{
return cand.clone();
let shells = ["/bin/sh", "/bin/bash", "bash"];
for shell in shells {
for locale in &locales {
let probe = std::process::Command::new(shell)
.arg("-c")
.arg("x=é; echo ${#x}")
.env("LC_ALL", locale)
.env("LANG", locale)
.output();
if let Ok(out) = probe
&& String::from_utf8_lossy(&out.stdout).trim() == "1"
{
return (shell.to_owned(), locale.clone());
}
}
}
panic!(
"no installed locale makes /bin/sh count characters, so the \
byte-vs-character distinction this row exists to test cannot \
be established here. Tried: {candidates:?}"
"no available shell counts characters under any installed \
locale, so the byte-versus-character distinction this row \
exists to test cannot be established here. Tried shells \
{shells:?} against locales {locales:?}"
);
}