diff --git a/docs/active-work.md b/docs/active-work.md index 4051a83..49408c1 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -288,7 +288,8 @@ from #171 and #215. responsibility to `TMPDIR` rather than adding a feature, which is why it amends this document instead of opening a new one. - **What it does:** every gate invocation gets a fresh, disk-backed - `TMPDIR` at `/tmp/`, exported once so every stage and + `TMPDIR` at `/tmp/`, exported once so every stage + and every process they spawn inherits it, and reaped by the same exit trap as the ambient root. **A gate run no longer needs a `TMPDIR=` override.** @@ -296,8 +297,11 @@ from #171 and #215. ANCESTOR marker — project detection walks upward — so a fresh subdirectory of `/tmp` inherits `/tmp`'s ancestors and the same stray `.git`. The directory had to move somewhere the gate already owns. -- **`SUN_LEN` is the constraint that shaped the layout, and the fix's - own gate run found it.** A Unix socket path cannot exceed 108 bytes +- **The socket-path limit shaped the layout, and the fix's own gate run + found it.** The budget is the **supported-platform floor of 103 + usable bytes** — Darwin's 104-byte array minus its terminating NUL, + not Linux's 108, because a Linux-derived limit passes where it is + written and bind-fails on the macOS leg. A path cannot exceed that and the suites bind sockets inside `TMPDIR`. The first placement, `$TARGET/gate-tmp/$STAMP-$$`, produced a 114-byte socket path and failed **six** daemon and attach tests with *"path must be shorter @@ -308,16 +312,25 @@ from #171 and #215. socket failures deep in a suite name a limit, not a cause. The guard fails immediately with the path, its length and what to shorten. **Its reserve is measured, not round**: the longest suffix a fixture - appends is 21 bytes, so 30 leaves ~40% headroom. An earlier - "generous" 45 **fired on the gate's own behaviour tests**, which run - the gate inside the gate and so reach 71 bytes — a guard that rejects - a legitimate configuration fails on every run rather than a rare one. -- **Witnesses (2), each mutation-checked:** `M-G-1` removes the export - → the propagation row alone; `M-G-2` stops the reaping → the cleanup - row alone. Propagation is observed in a **spawned child** (the - self-test's first step reports its own `$TMPDIR` into its log), - because the gate exporting a variable would only prove the gate can - export a variable. + appends is **33** bytes (`/.tmpXXXXXX/directory-target.sock`), so + **48** leaves ~45% headroom. Two earlier values were wrong in + OPPOSITE directions — a "generous" 45 that fired on the gate's own + behaviour tests, then a 30 that sat **below the real maximum** and + would have passed 76–78-byte paths. The suite now roots its nested + gates at a short base so it can SATISFY the unchanged production + guard rather than be exempted from it. +- **Witnesses, each mutation-checked.** `M-G-1b` keeps the assignment + and removes only `export` → the propagation row; its predecessor + `M-G-1` deleted both and so never proved inheritance. `M-G-2` stops + the reaping → the cleanup row. `M-G-3` removes the ancestor check → + the refusal row. `M-G-4` reverts to existence-only, and `M-G-5` + reverts **only** the language-marker arm → the marker-type row, which + 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. - **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 diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 47c6a21..b852072 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -283,8 +283,12 @@ commands, read `docs/active-work.md` immediately after this file. **FIXED 2026-08-13: `scripts/gate` now isolates `TMPDIR`.** Each invocation gets a directory created fresh by `mktemp -d` under **`/tmp/`** — the shared gate root, not the per-worktree - target, because a Unix socket path cannot exceed **`SUN_LEN`** (108 - bytes) and the suites bind sockets inside `TMPDIR`. It is exported + target, because a Unix socket path cannot exceed `sun_path` and the + suites bind sockets inside `TMPDIR`. **The budget is the + supported-platform floor: 103 usable bytes** — Darwin's 104-byte + array minus its terminating NUL, not Linux's 108, because a + Linux-derived limit passes where it is written and bind-fails on + the macOS leg. It is exported once so every stage and every process they spawn inherits it, and reaped by the same exit trap as the ambient root. **A gate run no longer needs a `TMPDIR=` override.** diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index 83b4f47..cdc4818 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -552,27 +552,32 @@ is empty) and adds **no test to that binary**. Occurrence 3 excluded reproduces the signature with *nothing added to the binary*, which is independent corroboration rather than a repeat of the same argument. -**AND ONE THING IT INTRODUCES, NAMED RATHER THAN DISMISSED — the -observing lane is not environmentally neutral even though it is -code-neutral.** That lane moves the gate's `TMPDIR` off `/tmp`, which -on this machine changes the filesystem underneath every -`tempfile::tempdir()` in the run **from tmpfs to btrfs**. This test -creates a tempdir and runs a handshake against a **one-second -deadline** (`pmacs-gpu/src/attach.rs`). A slower filesystem under a -timing-bounded test, under full-sweep load, is a plausible mechanism -and **it did not exist in occurrences 1–3**. +**The observing lane is environmentally non-neutral, and that is +recorded as a CHANGE rather than as a mechanism.** It moves the gate's +`TMPDIR` off `/tmp`, which on this machine puts every +`tempfile::tempdir()` in the run on btrfs instead of tmpfs. Noted so a +later occurrence can compare like with like. -It is not established either: the failing I/O is on a `UnixStream::pair`, -not on the tempdir path, and the tempdir is created but never bound. -**What this row must not do is book the occurrence as "the usual flake" -when the observing lane changed the very conditions the flake is -sensitive to.** +**A causal claim built on that was advanced here and is WITHDRAWN.** +The draft argued the test's one-second deadline plus a slower +filesystem was a plausible new mechanism. It does not hold on +inspection: **the deadline bounds the connection RETRY loop, not the +socketpair handshake that returned `BrokenPipe`**, and the filesystem +work happens before that deadline is armed. The tempdir is created and +never bound — the failing I/O is on a `UnixStream::pair`. Recording a +mechanism that the code does not support is worse than recording none, +because the next occurrence gets measured against a story instead of +against the evidence. -**The discriminating comparison for a fifth occurrence** is therefore -the same lane's gate run with `TMPDIR` pointed back at a tmpfs — the -one variable this lane moves — rather than another merge-base control. -Three isolated re-runs on the current tree were green, which by this -file's own rule establishes intermittence only. +**So the causal status is unchanged by this occurrence: UNRESOLVED, +with no new mechanism.** What it adds is the corroboration above. Three +isolated re-runs on the current tree were green, which by this file's +own rule establishes intermittence only. + +**The discriminating comparison for a fifth occurrence** remains the +one the third occurrence prescribed. One occurrence, with no supported +mechanism, is not grounds to reverse a fix that closes two observed +hazards. **Third occurrence — worker identity Stage 1 review round 2, 2026-08-09, local (Linux). Same selector, same `gpu`-step flavor, all diff --git a/docs/gate-script-framing.md b/docs/gate-script-framing.md index ef86fce..b8839e7 100644 --- a/docs/gate-script-framing.md +++ b/docs/gate-script-framing.md @@ -452,7 +452,9 @@ Four decisions inside that, each of which had a cheaper wrong answer: therefore the marker. The directory has to sit somewhere with no marker above it. 2. **Off the GATE ROOT, not the per-worktree target.** A Unix socket - path cannot exceed `SUN_LEN` (108 bytes) and the suites bind sockets + path cannot exceed `sun_path` — **103 usable bytes at the supported + floor** (Darwin's 104-byte array minus its terminating NUL; Linux's + is 108/107) and the suites bind sockets *inside* `TMPDIR`. The per-worktree target is 60 bytes and the gate root 36; the first implementation used the former and produced 114-byte socket paths, failing six daemon and attach tests. **The @@ -492,8 +494,10 @@ other, which is the worst place to find out. **The usable PATH length is one less than the array**, because the stored value is NUL-terminated: 103 on Darwin, 107 on Linux. The script takes **103**. -**RULING — a synthetic nested gate does not pay a reserve it never -uses.** The reserve exists for fixtures that bind sockets under +**RULING — a synthetic nested gate is given room to SATISFY the +reserve; it is not exempted from it.** The guard is unchanged for every +run, nested or not. What changed is the layout the behaviour suite +hands it. The reserve exists for fixtures that bind sockets under `TMPDIR`. This script's own behaviour suite runs *nested* gates whose plans are synthetic (`true`, `false`, one `echo`) and which bind no socket at all, so applying the fixture reserve to them would reject a @@ -516,10 +520,13 @@ rather than the guard weakened**: plans create no markerless fixture — the same reason it may set the ancestor escape. -**The guard therefore keeps the true maximum for real runs**, and the -tests stop paying for a hazard they cannot encounter. If a future -behaviour row *does* bind a socket, it must move off the short base and -take the reserve with it. +**The guard therefore keeps the true maximum for every run**, and the +suite simply stops handing it a root that cannot clear it. An earlier +draft of this section said nested gates "do not pay" the reserve, which +is wrong and would have licensed exempting them: they pay it in full +and now have the headroom to afford it. If a future behaviour row binds +a socket, it must move off the short base — the reserve already covers +it. **Escape hatch, documented test-only.** `PMACS_GATE_ALLOW_ANCESTOR_MARKER` exists for this script's own diff --git a/scripts/gate b/scripts/gate index d1e2c4b..81db624 100755 --- a/scripts/gate +++ b/scripts/gate @@ -639,42 +639,45 @@ fi # markerless fixture exists for a marker to re-root. The check itself # is witnessed by a test that deliberately does NOT set this and # asserts the refusal. +gate_marker_refusal() { + echo "gate: a project marker sits above the gate TMPDIR:" >&2 + echo "gate: $1" >&2 + echo "gate: TMPDIR is $GATE_TMPDIR" >&2 + echo "gate: every markerless test fixture beneath it would be" >&2 + echo "gate: re-rooted at that directory. Move the gate root" >&2 + echo "gate: (PMACS_GATE_TARGET_ROOT) somewhere without one." >&2 + exit 2 +} + if [ -z "${PMACS_GATE_ALLOW_ANCESTOR_MARKER:-}" ]; then -for _anc in $( - _p="$GATE_TMPDIR" - while [ "$_p" != "/" ] && [ -n "$_p" ]; do - printf '%s\n' "$_p" - _p=$(dirname "$_p") - done - printf '/\n' -); do - # TYPE MATTERS, and an existence-only test is wrong in both - # directions. `match_marker` in `src/project.rs` requires `.git` to - # be a DIRECTORY and the seven language markers to be FILES, so - # `[ -e ]` would reject ancestors detection itself ignores --- most - # importantly a `.git` FILE, which is exactly what a git WORKTREE - # has. Every worktree in this repo would have tripped it. +# CANONICAL, AND QUOTED. Two defects an obvious loop has: +# +# * `for _anc in $(...)` WORD-SPLITS on IFS, so a gate root containing +# a space is torn into fragments and the real ancestor is never +# tested --- the check would pass on exactly the path it should +# reject. +# * `dirname` walks LEXICAL ancestry. `detect_project` canonicalizes, +# so a symlinked root can hide a marker the editor plainly sees. +# Resolving first makes the two agree. +_anc=$(cd "$GATE_TMPDIR" 2>/dev/null && pwd -P) || _anc="$GATE_TMPDIR" +while :; do for _m in Cargo.toml .luarc.json pyproject.toml go.mod deno.json \ deno.jsonc package.json; do + # TYPE MATTERS, and an existence-only test is wrong in both + # directions. `match_marker` in `src/project.rs` requires `.git` + # to be a DIRECTORY and the seven language markers to be FILES, + # so `[ -e ]` would reject ancestors detection itself ignores. if [ -f "$_anc/$_m" ]; then - echo "gate: a project marker sits above the gate TMPDIR:" >&2 - echo "gate: $_anc/$_m" >&2 - echo "gate: TMPDIR is $GATE_TMPDIR" >&2 - echo "gate: every markerless test fixture beneath it would be" >&2 - echo "gate: re-rooted at that directory. Move the gate root" >&2 - echo "gate: (PMACS_GATE_TARGET_ROOT) somewhere without one." >&2 - exit 2 + gate_marker_refusal "$_anc/$_m" fi done + # The one directory-valued marker. A git WORKTREE has a `.git` + # FILE, which detection ignores and this must too. if [ -d "$_anc/.git" ]; then - echo "gate: a project marker sits above the gate TMPDIR:" >&2 - echo "gate: $_anc/.git" >&2 - echo "gate: TMPDIR is $GATE_TMPDIR" >&2 - echo "gate: every markerless test fixture beneath it would be" >&2 - echo "gate: re-rooted at that directory. Move the gate root" >&2 - echo "gate: (PMACS_GATE_TARGET_ROOT) somewhere without one." >&2 - exit 2 + gate_marker_refusal "$_anc/.git" fi + [ "$_anc" = "/" ] && break + _anc=$(dirname "$_anc") done fi diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index a07eb42..453afc9 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -694,7 +694,21 @@ fn the_ancestor_check_honours_marker_types() { ); // The same name as a DIRECTORY is a real marker. + // A LANGUAGE marker as a DIRECTORY: detection wants a file, so the + // guard must ignore it. Without this row, reverting only the + // language-marker arm to `[ -e ]` stays green — the `.git` halves + // below constrain the directory-valued marker alone. std::fs::remove_file(&mine).expect("rm git file"); + let cargo_dir = base.path().join("Cargo.toml"); + std::fs::create_dir(&cargo_dir).expect("cargo dir"); + let err = String::from_utf8_lossy(&run_it().stderr).into_owned(); + assert!( + !err.contains(&format!("{}", cargo_dir.display())), + "a `Cargo.toml` DIRECTORY is not a marker to detection and must \ + not be one here; stderr:\n{err}" + ); + std::fs::remove_dir(&cargo_dir).expect("rm cargo dir"); + std::fs::create_dir(&mine).expect("git dir"); let out = run_it(); let err = String::from_utf8_lossy(&out.stderr).into_owned(); @@ -708,6 +722,119 @@ fn the_ancestor_check_honours_marker_types() { ); } +/// The socket-path guard: rejection, acceptance, and cleanup on +/// rejection. +/// +/// **The ordinary gate only ever exercises the passing side.** Every +/// other row here runs with a short root, so the guard is silent and a +/// broken guard would look identical. These construct the boundary +/// deliberately. +/// +/// The budget is **103 usable bytes** — Darwin's `sun_path[104]` minus +/// its terminating NUL, the supported-platform floor — less a 48-byte +/// fixture reserve, so a root whose derived TMPDIR exceeds 55 bytes is +/// refused. +#[test] +fn the_socket_path_guard_rejects_accepts_and_cleans_up() { + // The gate appends `/tmp/XXXXXX` (11 bytes) to the root, so a root + // of N bytes yields a TMPDIR of N + 11. + let over_root = long_root(60); + let out = run_guarded(over_root.path()); + let err = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + !out.status.success(), + "a root past the budget must be refused; stderr:\n{err}" + ); + assert!( + err.contains("too long for a unix socket path"), + "and must say why; stderr:\n{err}" + ); + + // REJECTION MUST NOT LEAK. The guard creates both temporary areas + // before it can measure anything, so a rejection that exits before + // the trap is armed leaves them behind — on every rejection, which + // is worse than the failure it prevents. + let leaked: Vec<_> = walkdir_shallow(over_root.path()); + assert!( + leaked.is_empty(), + "a rejected run must reap what it created; found {leaked:?}" + ); + + // Just inside the budget: accepted. + let ok_root = long_root(40); + let out = run_guarded(ok_root.path()); + let err = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + !err.contains("too long for a unix socket path"), + "a root inside the budget must not be refused; stderr:\n{err}" + ); +} + +/// The guard counts BYTES, not characters. +/// +/// `${#var}` counts characters under a UTF-8 locale while `sun_path` is +/// byte-limited, so a multibyte path measures short and passes a check +/// it should fail. Each `é` here is one character and **two bytes**. +#[test] +fn the_socket_path_guard_counts_bytes_not_characters() { + // Character-length ~34 but byte-length ~68: rejected only if the + // guard measures bytes. + let root = tempfile::Builder::new() + .prefix(&"é".repeat(24)) + .tempdir_in(short_root_base()) + .expect("multibyte root"); + let chars = root.path().to_string_lossy().chars().count(); + let bytes = root.path().to_string_lossy().len(); + assert!( + bytes > chars, + "fixture must actually be multibyte: {chars} chars, {bytes} bytes" + ); + + let out = run_guarded(root.path()); + let err = String::from_utf8_lossy(&out.stderr).into_owned(); + assert!( + !out.status.success() && err.contains("too long for a unix socket path"), + "a multibyte root over the BYTE budget must be refused — it is \ + {chars} characters but {bytes} bytes; stderr:\n{err}" + ); +} + +/// A root of exactly `total` bytes, so the boundary can be aimed at. +fn long_root(total: usize) -> tempfile::TempDir { + let base = short_root_base(); + // `/XXXXXX` + let fixed = base.display().to_string().len() + 1 + 6; + let pad = total.saturating_sub(fixed); + tempfile::Builder::new() + .prefix(&"a".repeat(pad)) + .tempdir_in(&base) + .expect("sized root") +} + +/// Run the gate with the ancestor escape set (so only the LENGTH guard +/// can speak) and no ambient `TMPDIR`. +fn run_guarded(root: &Path) -> std::process::Output { + std::process::Command::new(gate()) + .arg("--self-test") + .current_dir(repo_root()) + .env("PMACS_GATE_TARGET_ROOT", root) + .env("PMACS_GATE_ALLOW_ANCESTOR_MARKER", "1") + .env_remove("TMPDIR") + .output() + .expect("run gate") +} + +/// Entries left under `root/tmp` and any `gate-ambient` leaf. +fn walkdir_shallow(root: &Path) -> Vec { + let mut out = Vec::new(); + for sub in ["tmp"] { + if let Ok(rd) = std::fs::read_dir(root.join(sub)) { + out.extend(rd.filter_map(Result::ok).map(|e| e.path())); + } + } + out +} + /// The seam handoff §3 keeps authority over: a script cannot infer /// which acceptance suites a change touched, so it runs what it is /// handed — each one, in order.