From 2ce7802ed07138efe0771351173bd95f07c79fbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:27:27 +0000 Subject: [PATCH 1/9] build(deps): bump the github-actions group with 2 updates Bumps the github-actions group with 2 updates: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) and [actions/deploy-pages](https://github.com/actions/deploy-pages). Updates `docker/setup-qemu-action` from 4.2.0 to 4.3.0 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/96fe6ef7f33517b61c61be40b68a1882f3264fb8...1f40c72289eff860ee54a304f1438e3cff362e0a) Updates `actions/deploy-pages` from 5.0.0 to 5.0.1 - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/cd2ce8fcbc39b97be8ca5fce6e763baed58fa128...368f82528645a54fb793d4d04e342629a3f51346) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/deploy-pages dependency-version: 5.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/docker.yml | 2 +- .github/workflows/pages.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2c2d22e3..424a4bf1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up QEMU - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 - name: Set up Docker buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 6578ea4f..798b0c59 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -64,4 +64,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 From ab881daef1c09c09686d2e0786571bcb9f2c41cb Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 12 Sep 2026 00:56:03 -0500 Subject: [PATCH 2/9] fix(ci): bound SIGUSR1 fixtures and support macOS shells Check portable sentinel substitution, bound owned-child teardown, and preserve cleanup across repeated signals. Reject incomplete SIGUSR1 populations and nonzero child exits. Support Bash 3 cleanup ownership and BSD wc padding. Validation: 10 focused commands completed; SIGUSR1 default 15/15 and forced 13/13; strict 11/11; replay 6/6; portability 25/25; cleanup-deferral-disabled control failed with the required delivered-signal/live-child witness. Full remote release/ASan/macOS CI required before merge. Addresses #1126. --- .github/workflows/ci.yml | 34 +++++++ tests/run_all_tests.sh | 24 +++-- tests/sigusr1_support.sh | 87 +++++++++++++++++ tests/test_ci_portability.sh | 182 +++++++++++++++++++++++++++++++++++ tests/test_sigusr1_dump.sh | 41 +++++--- tools/replay_diff.sh | 12 ++- tools/strict_differential.sh | 7 +- 7 files changed, 360 insertions(+), 27 deletions(-) create mode 100644 tests/sigusr1_support.sh create mode 100644 tests/test_ci_portability.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4548bc95..4b71b983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -303,6 +303,23 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - if: needs.scope.outputs.code == 'true' + name: Shell harness portability controls + shell: bash + run: | + result=$(mktemp) + rc=0 + bash tests/test_ci_portability.sh > "$result" 2>&1 || rc=$? + cat "$result" + [ "$rc" -eq 0 ] || exit "$rc" + [ "$(grep -cx 'ci-portability: checks=25 failures=0' "$result")" -eq 1 ] + rc=0 + bash tests/test_ci_portability.sh --no-cleanup-deferral > "$result" 2>&1 || rc=$? + cat "$result" + [ "$rc" -eq 1 ] + grep -qxF 'FAIL: cleanup deferral fault abandoned READY child after delivered signal and matching trap exit' "$result" + ! grep -q '^ci-portability: checks=' "$result" + - if: needs.scope.outputs.code == 'true' name: Build run: CC=clang ./build.sh @@ -347,6 +364,23 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - if: needs.scope.outputs.code == 'true' + name: Shell harness portability controls + shell: bash + run: | + result=$(mktemp) + rc=0 + bash tests/test_ci_portability.sh > "$result" 2>&1 || rc=$? + cat "$result" + [ "$rc" -eq 0 ] || exit "$rc" + [ "$(grep -cx 'ci-portability: checks=25 failures=0' "$result")" -eq 1 ] + rc=0 + bash tests/test_ci_portability.sh --no-cleanup-deferral > "$result" 2>&1 || rc=$? + cat "$result" + [ "$rc" -eq 1 ] + grep -qxF 'FAIL: cleanup deferral fault abandoned READY child after delivered signal and matching trap exit' "$result" + ! grep -q '^ci-portability: checks=' "$result" + - if: needs.scope.outputs.code == 'true' name: JIT emitter smoke tests run: make jit-smoke diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 54ff100f..cb93a866 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -6398,17 +6398,21 @@ echo "" # on observable state (READY/DONE markers), never sleeps — see # tests/test_sigusr1_dump.sh. echo "[99e] SIGUSR1 observer dump (#660)" -OD_OUTPUT=$(bash "$TESTS_DIR/test_sigusr1_dump.sh" 2>&1) -OD_PASS=$(echo "$OD_OUTPUT" | grep -c "PASS:" || true) -OD_FAIL=$(echo "$OD_OUTPUT" | grep -c "FAIL:" || true) -TOTAL=$((TOTAL + OD_PASS + OD_FAIL)) -PASS=$((PASS + OD_PASS)) -FAIL=$((FAIL + OD_FAIL)) -if [ "$OD_FAIL" -gt 0 ]; then - echo " FAIL: $OD_FAIL SIGUSR1 dump check(s) failed" - echo "$OD_OUTPUT" | grep "FAIL:" | head -5 -else +OD_OUTPUT=$(bash "$TESTS_DIR/test_sigusr1_dump.sh" 2>&1); OD_RC=$? +. "$TESTS_DIR/sigusr1_support.sh" +if OD_REASON=$(sigusr1_result_check "$OD_OUTPUT" "$OD_RC" 2>&1); then + OD_PASS=$(printf '%s\n' "$OD_OUTPUT" | grep -c '^PASS: ' || true) + TOTAL=$((TOTAL + OD_PASS)); PASS=$((PASS + OD_PASS)) echo " PASS: all $OD_PASS SIGUSR1 dump checks" +else + OD_PASS=$(printf '%s\n' "$OD_OUTPUT" | grep -c '^PASS: ' || true) + OD_FAIL=$(printf '%s\n' "$OD_OUTPUT" | grep -c '^FAIL: ' || true) + # A child that exited early, silently, or after passing assertions is one + # explicit failure even when it supplied no FAIL marker of its own. + [ "$OD_FAIL" -gt 0 ] || OD_FAIL=1 + TOTAL=$((TOTAL + OD_PASS + OD_FAIL)); PASS=$((PASS + OD_PASS)); FAIL=$((FAIL + OD_FAIL)) + echo " FAIL: $OD_REASON" + printf '%s\n' "$OD_OUTPUT" fi echo "" diff --git a/tests/sigusr1_support.sh b/tests/sigusr1_support.sh new file mode 100644 index 00000000..b33489d5 --- /dev/null +++ b/tests/sigusr1_support.sh @@ -0,0 +1,87 @@ +# Small Bash 3-compatible helpers for test_sigusr1_dump.sh's owned child. +# No process-name searches, new session, or timeout utility dependency. +sigusr1_replace_sentinel() { + local file=$1 marker=$2 sentinel=$3 + if ! sed -i.bak "s|$marker|$sentinel|g" "$file"; then + echo "FAIL: sigusr1: sentinel substitution failed" >&2 + return 1 + fi + rm -f "$file.bak" + if grep -qF "$marker" "$file"; then + echo "FAIL: sigusr1: sentinel placeholder remains" >&2 + return 1 + fi +} + +sigusr1_running() { + local wanted=$1 child + # Bash retains the status for wait even after reaping. Consult our job + # table, not kill -0 on a PID that might already have been reused. + for child in $(jobs -pr; jobs -ps); do [ "$child" = "$wanted" ] && return 0; done + return 1 +} + +sigusr1_stop() { + local child=$1 i + if sigusr1_running "$child"; then kill -TERM "$child" 2>/dev/null || true; fi + for ((i=0; i<20; i++)); do + sigusr1_running "$child" || { wait "$child" 2>/dev/null; return 0; } + sleep 0.1 + done + if sigusr1_running "$child"; then kill -KILL "$child" 2>/dev/null || true; fi + for ((i=0; i<20; i++)); do + sigusr1_running "$child" || { wait "$child" 2>/dev/null; return 0; } + sleep 0.1 + done + echo "FAIL: sigusr1: owned child did not stop after KILL" >&2 + return 1 +} + +sigusr1_wait() { + local child=$1 tenths=$2 i + for ((i=0; i&2 + sigusr1_stop "$child" || return 125 + return 124 +} + +sigusr1_result_check() { + local output=$1 status=$2 closed second + SIGUSR1_PASS=$(printf '%s\n' "$output" | grep -c '^PASS: ' || true) + SIGUSR1_FAIL=$(printf '%s\n' "$output" | grep -c '^FAIL: ' || true) + if [ "$status" -ne 0 ]; then echo "sigusr1 result: child exit $status" >&2; return 1; fi + if [ "$SIGUSR1_FAIL" -ne 0 ]; then echo "sigusr1 result: failed assertions" >&2; return 1; fi + closed=$(printf '%s\n' "$output" | grep -c '^PASS: sigusr1: gated first dump declares absence of data (not equilibrium)$' || true) + second=$(printf '%s\n' "$output" | grep -c '^PASS: sigusr1: second dump arrived after the gate armed$' || true) + local expected matches clean leak + while IFS= read -r expected; do + matches=$(printf '%s\n' "$output" | grep -cFx "PASS: $expected" || true) + if [ "$matches" -ne 1 ]; then echo "sigusr1 result: missing/duplicate assertion: $expected" >&2; return 1; fi + done <<'CHECKS' +sigusr1: child reached its loop (READY barrier) +sigusr1: dump arrived at a loop safepoint +sigusr1: module row shape (name|value|when|entropy|dH|trajectory) with settled when +sigusr1: live-frame row with fresh when=1 binding (distinguishable from settled) +sigusr1: fn-local row carries its accumulated when count +sigusr1: program completed (DONE) after the dump +sigusr1: exit code 0 after the dump +sigusr1: no sanitizer report (single-thread run) +sigusr1-mt: child reached its loop with a task live +sigusr1-mt: dump arrived under a live task +sigusr1-mt: module row shape with settled when +sigusr1-mt: program completed (DONE) after the dump +CHECKS + clean=$(printf '%s\n' "$output" | grep -cFx 'PASS: sigusr1-mt: clean exit after the dump' || true) + leak=$(printf '%s\n' "$output" | grep -cFx 'PASS: sigusr1-mt: LeakSanitizer nonzero exit (known spawn-thread leak shape; tolerated like rc_ok)' || true) + if [ "$((clean+leak))" -ne 1 ]; then echo "sigusr1 result: missing/duplicate task exit assertion" >&2; return 1; fi + # Source has eight unconditional single-thread + five task-live checks; + # a closed observer gate adds exactly these two first/second-dump checks. + if ! { [ "$closed" -eq 0 ] && [ "$second" -eq 0 ] && [ "$SIGUSR1_PASS" -eq 13 ]; } && + ! { [ "$closed" -eq 1 ] && [ "$second" -eq 1 ] && [ "$SIGUSR1_PASS" -eq 15 ]; }; then + echo "sigusr1 result: incomplete check population" >&2 + return 1 + fi +} diff --git a/tests/test_ci_portability.sh b/tests/test_ci_portability.sh new file mode 100644 index 00000000..c20e3da1 --- /dev/null +++ b/tests/test_ci_portability.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Focused harness controls; no EigenScript binary/build required. +set -u +case "${1:-}" in ''|--no-cleanup-deferral) ;; *) exit 2;; esac +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$TESTS_DIR/sigusr1_support.sh" +TMP=$(mktemp -d) +PIDS=""; EXTRA_PID="" +cleanup() { + local status=$? p + trap '' INT TERM HUP + trap - EXIT + for p in $PIDS; do sigusr1_stop "$p" || status=1; done + if [ -n "$EXTRA_PID" ]; then kill -KILL "$EXTRA_PID" 2>/dev/null || true; fi + rm -rf "$TMP" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +checks=0 +check() { if ! "$@"; then echo "FAIL: portability control $*" >&2; exit 1; fi; checks=$((checks+1)); } + +# Restrict the fake sed seam to the actual GNU/BSD CLI distinction. Actual +# BSD sed/Bash3 execution remains a separate macOS validation requirement. +sed() { [ "$1" != '-i' ] || return 1; command sed "$@"; } +printf '%s\n' '@SENT1@' > "$TMP/fixture" +check sigusr1_replace_sentinel "$TMP/fixture" '@SENT1@' "$TMP/ready" +check test "$(cat "$TMP/fixture")" = "$TMP/ready" +check test ! -e "$TMP/fixture.bak" +# Original invocation must be rejected by the same seam, leaving placeholder. +printf '%s\n' '@SENT1@' > "$TMP/old" +if sed -i "s|@SENT1@|$TMP/ready|" "$TMP/old"; then exit 1; fi +check test "$(cat "$TMP/old")" = '@SENT1@' +# A substitution tool that succeeds without replacing anything is rejected. +if ( sed() { return 0; }; sigusr1_replace_sentinel "$TMP/old" '@SENT1@' "$TMP/ready" ) > "$TMP/retained.out" 2>&1; then exit 1; fi +check grep -qF 'FAIL: sigusr1: sentinel placeholder remains' "$TMP/retained.out" + +# Test the real strict EXIT helper and initializer without executing its gate. +awk '/^_sd_main_depth=/{print} /^_sd_exit\(\)/{copy=1} copy{print} copy && /^}/{exit}' "$TESTS_DIR/../tools/strict_differential.sh" > "$TMP/strict-helper" +if ! ( + unset BASHPID + TMP="$TMP/strict-parent"; mkdir "$TMP" + verdict_printed=1 + . "${TMP%/strict-parent}/strict-helper" + ( _sd_exit ) + [ -d "$TMP" ] || exit 41 + _sd_exit + [ ! -e "$TMP" ] || exit 42 +); then echo 'FAIL: strict subshell cleanup ownership' >&2; exit 1; fi +checks=$((checks+1)) +# A $$-only substitute must fail the same parent-directory witness. +command sed 's/"$BASH_SUBSHELL" = "${_sd_main_depth:-}"/"$$" = "$$"/' "$TMP/strict-helper" > "$TMP/wrong-helper" +( + TMP="$TMP/wrong-parent"; mkdir "$TMP"; verdict_printed=1 + . "${TMP%/wrong-parent}/wrong-helper" + ( _sd_exit ) + [ -d "$TMP" ] || exit 41 +) > "$TMP/wrong.out" 2>&1 +rc=$? +check test "$rc" -eq 41 +# Restore the original Bash-only variable requirement: nounset must reject it +# under the same missing-variable condition captured by the macOS CI log. +if ( unset BASHPID; eval '_sd_main_pid=$BASHPID' ) > "$TMP/old-bash.out" 2>&1; then exit 1; fi +check grep -qF 'BASHPID' "$TMP/old-bash.out" + +# Invoke the actual ledger helper with padded, unpadded and invalid wc output. +awk '/^ledger_count\(\)/{copy=1} copy{print} copy && /^}/{exit}' "$TESTS_DIR/../tools/replay_diff.sh" > "$TMP/count-helper" +. "$TMP/count-helper" +: > "$TMP/ledger" +wc() { printf ' 0\n'; } +check test "$(ledger_count "$TMP/ledger")" = 0 +wc() { printf '7\n'; } +check test "$(ledger_count "$TMP/ledger")" = 7 +wc() { printf 'garbage\n'; } +if ledger_count "$TMP/ledger" > "$TMP/invalid-count"; then exit 1; fi +check test ! -s "$TMP/invalid-count" +wc() { return 1; } +if ledger_count "$TMP/ledger" > "$TMP/failed-count"; then exit 1; fi +check test ! -s "$TMP/failed-count" + +# Direct owned child: normal wait preserves the real nonzero status. +python3 -c 'raise SystemExit(7)' & +PID=$!; PIDS="$PID" +sigusr1_wait "$PID" 20; rc=$? +check test "$rc" -eq 7 +PIDS="" +# A DONE marker does not mean exit; ignore TERM so KILL is exercised too. +: > "$TMP/done" +python3 -c 'import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); print("DONE",flush=True); time.sleep(60)' > "$TMP/done" & +PID=$!; PIDS="$PID" +for ((i=0; i<50; i++)); do grep -q '^DONE$' "$TMP/done" && break; sleep 0.1; done +check grep -q '^DONE$' "$TMP/done" +sigusr1_wait "$PID" 1 > "$TMP/wait.out" 2>&1; rc=$? +check test "$rc" -eq 124 +check grep -qF 'FAIL: sigusr1: child did not exit after DONE' "$TMP/wait.out" +if sigusr1_running "$PID"; then echo 'FAIL: owned child remains live' >&2; exit 1; fi +checks=$((checks+1)); PIDS="" +# Source-derived successful labels: choose the clean task exit and deduplicate +# its two existing source branches. These are helper fixtures, not VM results. +awk '/^[ ]*pass "/ { sub(/^[ ]*pass "/,"PASS: "); sub(/"$/,""); if (!seen[$0]++) print }' "$TESTS_DIR/test_sigusr1_dump.sh" | + grep -v 'LeakSanitizer nonzero exit' > "$TMP/closed-result" +closed_output=$(cat "$TMP/closed-result") +check sigusr1_result_check "$closed_output" 0 +open_output=$(grep -vE 'gated first dump declares|second dump arrived after' "$TMP/closed-result") +check sigusr1_result_check "$open_output" 0 +for mode in empty early badrc; do + case "$mode" in + empty) output=""; status=0; witness='missing/duplicate assertion' ;; + early) output='PASS: sigusr1: child reached its loop (READY barrier)'; status=0; witness='missing/duplicate assertion' ;; + badrc) output="$closed_output"; status=1; witness='child exit 1' ;; + esac + if sigusr1_result_check "$output" "$status" > "$TMP/consume-$mode" 2>&1; then exit 1; fi + check grep -qF "$witness" "$TMP/consume-$mode" +done +# Exercise the actual fixture cleanup after its first signal, then send three +# more signals while the TERM-resistant child is in TERM->KILL cleanup. +control="$TMP/repeated-cleanup.sh" +printf '%s\n' '#!/usr/bin/env bash' 'set -u' '. "$1"' 'DIR=$2' > "$control" +awk '/^cleanup\(\)/{copy=1} copy{print} copy && /^}/{exit}' "$TESTS_DIR/test_sigusr1_dump.sh" >> "$control" +if [ "${1:-}" = '--no-cleanup-deferral' ]; then + # Mutate only the extracted actual cleanup's one signal-ignore line. + [ "$(grep -cF " trap '' INT TERM HUP" "$control")" -eq 1 ] || exit 2 + command sed "/^ trap '' INT TERM HUP$/d" "$control" > "$control.fault" + mv "$control.fault" "$control" +fi +cat >> "$control" <<'CONTROL' +FIX1="$DIR/a"; FIX2="$DIR/b"; OUT1="$DIR/c"; OUT2="$DIR/d" +ERR1="$DIR/e"; ERR2="$DIR/f"; SENT1="$DIR/g"; SENT2="$DIR/h" +PIDS="" +trap cleanup EXIT +trap 'exit 143' TERM +trap 'exit 130' INT +trap 'exit 129' HUP +eval "$(declare -f sigusr1_stop | sed '1s/sigusr1_stop/sigusr1_stop_impl/')" +sigusr1_stop() { : > "$DIR/cleaning"; sigusr1_stop_impl "$@"; } +: > "$DIR/ready" +python3 -c 'import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); print("READY",flush=True); time.sleep(60)' > "$DIR/ready" & +PIDS=$!; printf '%s\n' "$PIDS" > "$DIR/child-pid" +for ((i=0; i<50; i++)); do grep -q '^READY$' "$DIR/ready" && break; sleep 0.1; done +grep -q '^READY$' "$DIR/ready" || { echo 'FAIL: repeated cleanup inner READY missing' >&2; exit 1; } +kill -TERM "$$" +CONTROL +mkdir "$TMP/repeated" +bash "$control" "$TESTS_DIR/sigusr1_support.sh" "$TMP/repeated" > "$TMP/repeated.out" 2>&1 & +PID=$!; PIDS="$PID" +for ((i=0; i<50; i++)); do [ -f "$TMP/repeated/cleaning" ] && break; sleep 0.1; done +if [ -f "$TMP/repeated/child-pid" ]; then EXTRA_PID=$(cat "$TMP/repeated/child-pid"); fi +if [ -z "$EXTRA_PID" ] || ! [ -f "$TMP/repeated/cleaning" ] || ! grep -q '^READY$' "$TMP/repeated/ready"; then + echo 'FAIL: repeated cleanup barrier missing' >&2; exit 1 +fi +checks=$((checks+1)) +sent_INT=0; sent_TERM=0; sent_HUP=0; delivery_failed=0 +for sig in INT TERM HUP; do + if kill -"$sig" "$PID"; then + case "$sig" in INT) sent_INT=1;; TERM) sent_TERM=1;; HUP) sent_HUP=1;; esac + else delivery_failed=1; fi +done +sigusr1_wait "$PID" 50; rc=$? +if [ "${1:-}" = '--no-cleanup-deferral' ]; then + matched=0 + case "$rc" in 130) matched=$sent_INT;; 143) matched=$sent_TERM;; 129) matched=$sent_HUP;; esac + child_state=$(ps -o stat= -p "$EXTRA_PID") + case "$child_state" in ''|*Z*) child_live=0;; *) child_live=1;; esac + if [ "$matched" -eq 1 ] && [ "$child_live" -eq 1 ] && kill -0 "$EXTRA_PID" 2>/dev/null; then + echo 'FAIL: cleanup deferral fault abandoned READY child after delivered signal and matching trap exit' >&2 + exit 1 + fi + echo "FAIL: cleanup deferral control unattributed (rc=$rc, matched=$matched, live=$child_live, delivery_failed=$delivery_failed)" >&2 + exit 1 +fi +if [ "$delivery_failed" -ne 0 ]; then echo 'FAIL: repeated cleanup signal delivery failed' >&2; exit 1; fi +if [ "$rc" -ne 143 ]; then echo "FAIL: repeated cleanup exit: $rc" >&2; exit 1; fi +checks=$((checks+1)) +if kill -0 "$EXTRA_PID" 2>/dev/null; then + kill -KILL "$EXTRA_PID" 2>/dev/null || true + echo 'FAIL: repeated signal interrupted child cleanup' >&2; exit 1 +fi +checks=$((checks+1)); PIDS=""; EXTRA_PID="" +[ "$checks" -eq 25 ] || { echo "FAIL: portability population $checks, expected25" >&2; exit 1; } +echo "ci-portability: checks=$checks failures=0" diff --git a/tests/test_sigusr1_dump.sh b/tests/test_sigusr1_dump.sh index 663f7441..c1635669 100644 --- a/tests/test_sigusr1_dump.sh +++ b/tests/test_sigusr1_dump.sh @@ -40,6 +40,7 @@ set -u TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" EIGS="$TESTS_DIR/../src/eigenscript" +. "$TESTS_DIR/sigusr1_support.sh" FIX1=/tmp/eigs_sigusr1_a_$$.eigs FIX2=/tmp/eigs_sigusr1_b_$$.eigs @@ -54,16 +55,23 @@ ERR2=/tmp/eigs_sigusr1_b_$$.err PIDS="" cleanup() { + local status=$? + trap '' INT TERM HUP + trap - EXIT for p in $PIDS; do - kill "$p" 2>/dev/null || true - wait "$p" 2>/dev/null || true + sigusr1_stop "$p" || status=1 done - rm -f "$FIX1" "$FIX2" "$OUT1" "$ERR1" "$OUT2" "$ERR2" "$SENT1" "$SENT2" + rm -f "$FIX1" "$FIX2" "$OUT1" "$ERR1" "$OUT2" "$ERR2" "$SENT1" "$SENT2" "$FIX1.bak" "$FIX2.bak" + exit "$status" } trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +FAILED=0 pass() { echo "PASS: $1"; } -fail() { echo "FAIL: $1"; } +fail() { echo "FAIL: $1"; FAILED=1; } # poll_file — wait up to /10 seconds for # grep -qE to succeed (observable-state barrier). @@ -95,7 +103,7 @@ define train(n) as: r is train of 2000000000 print of "DONE" EOF -sed -i "s|@SENT1@|$SENT1|" "$FIX1" +sigusr1_replace_sentinel "$FIX1" "@SENT1@" "$SENT1" || exit 1 # ---- Subtest 1: single-thread dump shape ------------------------------- @@ -107,6 +115,7 @@ if poll_file "$OUT1" "^READY$" 600; then pass "sigusr1: child reached its loop (READY barrier)" else fail "sigusr1: child never printed READY (dead or hung before the loop)" + exit 1 fi if ! kill -USR1 "$PID" 2>/dev/null; then @@ -117,6 +126,7 @@ if poll_file "$ERR1" "^# end dump$" 600; then pass "sigusr1: dump arrived at a loop safepoint" else fail "sigusr1: no dump on stderr after SIGUSR1" + exit 1 fi # #915: if the observer gate is closed for this fixture (it has no observer @@ -141,6 +151,7 @@ if grep -q "observer gate CLOSED" "$ERR1"; then pass "sigusr1: second dump arrived after the gate armed" else fail "sigusr1: no second dump after the gate armed" + exit 1 fi fi @@ -175,15 +186,16 @@ fi : > "$SENT1" # The program must continue correctly after the dump: DONE marker, then a -# clean exit on its own. DONE is the observable barrier; the wait below only -# reaps (teardown after DONE cannot hang). +# clean exit on its own. DONE is a barrier, not proof that teardown exited; +# the owned-child wait is separately bounded. if poll_file "$OUT1" "^DONE$" 1200; then pass "sigusr1: program completed (DONE) after the dump" else fail "sigusr1: program never completed after the dump" + exit 1 fi -wait "$PID"; RC1=$? -PIDS="${PIDS% $PID}" +sigusr1_wait "$PID" 100; RC1=$? +if ! sigusr1_running "$PID"; then PIDS="${PIDS% $PID}"; fi if [ "$RC1" = "0" ]; then pass "sigusr1: exit code 0 after the dump" else @@ -227,7 +239,7 @@ r is train of 2000000000 thread_join of t print of "DONE" EOF -sed -i "s|@SENT2@|$SENT2|" "$FIX2" +sigusr1_replace_sentinel "$FIX2" "@SENT2@" "$SENT2" || exit 1 "$EIGS" "$FIX2" > "$OUT2" 2> "$ERR2" & PID=$! @@ -237,6 +249,7 @@ if poll_file "$OUT2" "^READY$" 600; then pass "sigusr1-mt: child reached its loop with a task live" else fail "sigusr1-mt: child never printed READY" + exit 1 fi if ! kill -USR1 "$PID" 2>/dev/null; then @@ -249,6 +262,7 @@ if poll_file "$ERR2" "^# end dump$" 600; then pass "sigusr1-mt: dump arrived under a live task" else fail "sigusr1-mt: no dump on stderr after SIGUSR1" + exit 1 fi if grep -qE '^step_count \| [0-9]+ \| when=[0-9]{4,} \| entropy=[^ ]+ \| dH=[^ ]+ \| [a-z]+$' "$ERR2"; then @@ -264,9 +278,10 @@ if poll_file "$OUT2" "^DONE$" 1200; then pass "sigusr1-mt: program completed (DONE) after the dump" else fail "sigusr1-mt: program never completed after the dump" + exit 1 fi -wait "$PID"; RC2=$? -PIDS="${PIDS% $PID}" +sigusr1_wait "$PID" 100; RC2=$? +if ! sigusr1_running "$PID"; then PIDS="${PIDS% $PID}"; fi # Share the main runner's rc_ok classification rather than restating it: a # LeakSanitizer nonzero exit from the known spawn-thread leak shapes counts as @@ -301,3 +316,5 @@ case $? in fi ;; esac + +exit "$FAILED" diff --git a/tools/replay_diff.sh b/tools/replay_diff.sh index 90f5ba58..7854829e 100755 --- a/tools/replay_diff.sh +++ b/tools/replay_diff.sh @@ -47,6 +47,13 @@ elif command -v gtimeout >/dev/null 2>&1; then TMO="gtimeout 180"; fi corpus_dir() { printf '%s' "${REPLAY_DIFF_CORPUS:-$ROOT/tests}"; } eig_bin() { printf '%s' "${REPLAY_DIFF_EIG:-./eigenscript}"; } norm() { sed -E 's/0x[0-9a-f]+/0xADDR/g' "$1"; } +ledger_count() { + local count + count=$(wc -l < "$1") || return 1 + count=${count//[[:space:]]/} + case "$count" in ''|*[!0-9]*) return 1;; esac + printf '%s' "$count" +} # stdin is pinned to /dev/null: test_terminal's raw_key reads it, and with the # harness's inherited stdin the record arm hung (rc 124) while the replay arm # exited 3 -- a phantom row from the environment, not the tape. @@ -190,10 +197,11 @@ if [ "$crash" -gt 0 ]; then echo "replay_diff: FAIL: $crash signal exit(s) -- a crash is never a boundary, whatever the arm printed (#1112); $n programs, $boundary at the documented boundary, $nondet nondeterministic" exit 1 fi -if [ "${1:-}" = "--record" ]; then cp "$got" "$BASE"; echo "replay_diff: baseline recorded ($(wc -l < "$BASE") rows, $n programs, $boundary at the documented boundary, $nondet nondeterministic)"; exit 0; fi +if [ "${1:-}" = "--record" ]; then cp "$got" "$BASE"; count=$(ledger_count "$BASE") || { echo "replay_diff: FAIL: invalid ledger count"; exit 1; }; echo "replay_diff: baseline recorded ($count rows, $n programs, $boundary at the documented boundary, $nondet nondeterministic)"; exit 0; fi [ -f "$BASE" ] || { echo "replay_diff: no baseline at $BASE (run with --record)"; cat "$got"; exit 1; } if diff <(sort "$BASE") "$got" > "$T/d"; then - echo "replay_diff: OK ($n programs record+replay; $boundary at the documented boundary; $nondet nondeterministic; $(wc -l < "$BASE") ledgered)"; exit 0 + count=$(ledger_count "$BASE") || { echo "replay_diff: FAIL: invalid ledger count"; exit 1; } + echo "replay_diff: OK ($n programs record+replay; $boundary at the documented boundary; $nondet nondeterministic; $count ledgered)"; exit 0 fi echo "replay_diff: LEDGER CHANGED ($n programs examined)" echo " '<' = ledgered and now identical (improvement -- remove it)" diff --git a/tools/strict_differential.sh b/tools/strict_differential.sh index b24d7ccd..7d480d1c 100755 --- a/tools/strict_differential.sh +++ b/tools/strict_differential.sh @@ -194,15 +194,16 @@ TMP="$(mktemp -d)" # diagnostic re-run printed a completely clean report, because the failing run # was discarded rather than shown. If that happens again this line says so. verdict_printed=0 -_sd_main_pid=$BASHPID +_sd_main_depth=$BASH_SUBSHELL _sd_exit() { local es=$? # ONLY the top-level shell. bash runs an EXIT trap in a subshell that is # killed by a signal too, and this trap both deletes $TMP and speaks: a # signalled command substitution would otherwise remove the temp dir out # from under the still-running parent and print ABORTED before the parent - # reaches its verdict. $BASHPID is per-subshell where $$ is not. - [ "$BASHPID" = "${_sd_main_pid:-}" ] || return 0 + # reaches its verdict. Bash 3 supplies BASH_SUBSHELL; $$ cannot distinguish + # these inherited subshells, and BASHPID requires a newer Bash. + [ "$BASH_SUBSHELL" = "${_sd_main_depth:-}" ] || return 0 rm -rf "${TMP:-}" if [ "${verdict_printed:-0}" != "1" ]; then if [ "$es" = "0" ]; then From 39154c5b5754b78d4d711674f74dfb66fbf62c9d Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 12 Sep 2026 01:05:08 -0500 Subject: [PATCH 3/9] fix(ci): split full extension suites into required workers Run HTTP/model, gfx, zlib and net suites in separate fresh jobs with the existing 30-minute limits. Preserve the current required extensions check as an always-running aggregate that rejects every unsuccessful worker. Validation: exact coverage of all 18 original non-checkout steps; dropped and duplicated suite mutations rejected; 18 aggregate status cases passed. Independent root rerun exit 0, chunk97b521. Actual four-worker CI pending. Addresses #1126. --- .github/workflows/ci.yml | 109 ++++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b71b983..58079a44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -348,8 +348,38 @@ jobs: # (eigs_embed.c is otherwise 0% — embed-smoke is the only thing that runs # it), compile-check the LSP (bitrots invisibly otherwise), and run the # standalone JIT emitter smoke tests. + # Preserve the existing required check name while requiring every variant. + # Each worker still checks out on docs-only changes; its code steps skip. + # A failed/cancelled/skipped worker is never an aggregate success. extensions: name: extensions (http+model+gfx suite; embed/lsp/jit-smoke) + needs: [scope, extensions-http, extensions-gfx, extensions-zlib, extensions-net] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Require complete extension coverage + shell: bash + env: + SCOPE_RESULT: ${{ needs.scope.result }} + CODE: ${{ needs.scope.outputs.code }} + HTTP_RESULT: ${{ needs.extensions-http.result }} + GFX_RESULT: ${{ needs.extensions-gfx.result }} + ZLIB_RESULT: ${{ needs.extensions-zlib.result }} + NET_RESULT: ${{ needs.extensions-net.result }} + run: | + set -euo pipefail + [ "$SCOPE_RESULT" = success ] + case "$CODE" in true|false) ;; *) exit 1;; esac + for result in "$HTTP_RESULT" "$GFX_RESULT" "$ZLIB_RESULT" "$NET_RESULT"; do + [ "$result" = success ] || exit 1 + done + echo "Extension coverage complete (code=$CODE; four workers succeeded)" + + extensions-http: + name: extensions / http+model and ancillary checks needs: [dev-image, scope] runs-on: ubuntu-latest timeout-minutes: 30 @@ -418,6 +448,37 @@ jobs: name: Run full suite against http+model build run: cd tests && bash run_all_tests.sh + - if: needs.scope.outputs.code == 'true' + name: Compile-check LSP + run: make lsp + + - if: needs.scope.outputs.code == 'true' + name: Compile-check DAP server + run: make dap + + # 44 deterministic adversarial inputs against the ASan-instrumented + # stdin harness (same compile->vm pipeline as main.c). Seconds to run; + # any exit other than 0/1 is a crash. + - if: needs.scope.outputs.code == 'true' + name: Fuzz smoke (ASan harness, curated corpus) + run: make fuzz && bash fuzz/run_fuzz.sh + + extensions-gfx: + name: extensions / gfx full suite + needs: [dev-image, scope] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: read + container: + image: ${{ needs.dev-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - if: needs.scope.outputs.code == 'true' name: Build gfx variant run: make gfx @@ -426,6 +487,23 @@ jobs: name: Run full suite against gfx build (audio [62], containment [132], gfx examples [97]) run: cd tests && bash run_all_tests.sh + + extensions-zlib: + name: extensions / zlib full suite + needs: [dev-image, scope] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: read + container: + image: ${{ needs.dev-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - if: needs.scope.outputs.code == 'true' name: Build zlib variant run: make zlib @@ -434,6 +512,23 @@ jobs: name: Run full suite against zlib build (executes DEFLATE section [124]) run: cd tests && bash run_all_tests.sh + + extensions-net: + name: extensions / net full suite + needs: [dev-image, scope] + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: read + container: + image: ${{ needs.dev-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - if: needs.scope.outputs.code == 'true' name: Build net variant run: make net @@ -442,20 +537,6 @@ jobs: name: Run full suite against net build (executes network section [125]) run: cd tests && bash run_all_tests.sh - - if: needs.scope.outputs.code == 'true' - name: Compile-check LSP - run: make lsp - - - if: needs.scope.outputs.code == 'true' - name: Compile-check DAP server - run: make dap - - # 44 deterministic adversarial inputs against the ASan-instrumented - # stdin harness (same compile->vm pipeline as main.c). Seconds to run; - # any exit other than 0/1 is a crash. - - if: needs.scope.outputs.code == 'true' - name: Fuzz smoke (ASan harness, curated corpus) - run: make fuzz && bash fuzz/run_fuzz.sh # Build the `full` variant (http+model+db) against a real PostgreSQL service # and run the suite with DATABASE_URL set. Without this, ext_db.c never From 56da89ef21585ed69bfb10362ddb8811fc751d34 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 12 Sep 2026 02:15:55 -0500 Subject: [PATCH 4/9] fix(lint): bound missing-file JSON diagnostic messages Assemble E000 before applying the existing UTF-8 message limit. Keep short messages byte-identical and preserve the separate file-field behavior. Cover short, long ASCII and multibyte missing paths with exact case enrollment. Fixes #1132. Validation: 10 bounded local commands; 3 original-path CLI checks and 3 dedicated cases; old-binary and missing-case faults both rejected. UTF-8 gate: 40 checks, 30 documented codes, 8 exemptions, 2000 identifier sweep runs, 1125 source-byte runs. All 9 original gate selftests passed. 1036 frozen inputs unchanged. Full release/ASan CI remains required before merge. Evidence: /tmp/ems-close-20260911/lint-e000-focused-r1/result.json SHA256: 4aa4729cdaa784b41e155591acafee5d7728bf48a2808aed568a1147640c23d5 --- docs/DIAGNOSTICS.md | 5 ++++ src/lint_host.c | 10 +++++--- tools/lint_e000_check.py | 43 ++++++++++++++++++++++++++++++++ tools/lint_message_utf8_check.sh | 10 +++++++- 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 tools/lint_e000_check.py diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index 3dc937c1..49aa987e 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -303,6 +303,11 @@ so `--lint --json 2>/dev/null` is pure JSON). Each element is: errors show `line:col` too, and the LSP diagnostic range starts at that column. (Warning elements are line-only for now — per-warning spans are the remaining #407 work.) +- An unreadable file emits `E000` with exit 1. Its decoded JSON `message` + follows the same 255-byte UTF-8 limit: a long `cannot read file '…'` message + retains the longest complete character prefix fitting 252 bytes, followed + by `...` (#1132). The separate `file` field keeps its existing path escaping + and output-buffer budget; clipping the message does not shorten that field. - Exit code follows `--lint-level` (see below); the default fails on any surviving warning. diff --git a/src/lint_host.c b/src/lint_host.c index 67bd78df..30aa6c34 100644 --- a/src/lint_host.c +++ b/src/lint_host.c @@ -1292,11 +1292,15 @@ int eigenscript_lint(const char *path, int json_mode, int fail_on_warning) { char *source = read_file_util(path, &src_size); if (!source) { if (json_mode) { - char esc[256], pesc[1024]; - lint_json_escape("cannot read file", esc, sizeof(esc)); + char rendered[1024], message[256], esc[512], pesc[1024]; + /* Bound the assembled message before JSON escaping, as lint_vdiag + * does; the file field keeps its separate path budget. */ + snprintf(rendered, sizeof(rendered), "cannot read file '%s'", path); + eigs_utf8_sanitize(message, sizeof(message), rendered); + lint_json_escape(message, esc, sizeof(esc)); lint_json_escape(path, pesc, sizeof(pesc)); printf("[{\"code\":\"E000\",\"severity\":\"error\",\"line\":0," - "\"file\":\"%s\",\"message\":\"%s '%s'\"}]\n", pesc, esc, pesc); + "\"file\":\"%s\",\"message\":\"%s\"}]\n", pesc, esc); } else { fprintf(stderr, "Error: cannot read file '%s'\n", dpath); } diff --git a/tools/lint_e000_check.py b/tools/lint_e000_check.py new file mode 100644 index 00000000..433f799f --- /dev/null +++ b/tools/lint_e000_check.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Missing-file JSON contract independent of the host TMPDIR length.""" +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +def validate(raw, path, clipped): + rows=json.loads(raw.decode('utf-8')) + assert len(rows)==1, 'E000 diagnostic population' + row=rows[0] + assert row['code']=='E000' and row['severity']=='error' and row['line']==0, 'E000 classification' + assert row['file']==path, 'E000 file path altered' + message=row['message'] + assert isinstance(message,str) and len(message.encode('utf-8'))<=255, 'E000 message exceeds 255 bytes' + full=("cannot read file '%s'" % path).encode('utf-8') + assert (len(full)>255)==clipped, 'E000 fixture does not exercise intended length' + # Valid UTF-8 fixture text: the longest complete prefix fitting 252 bytes + # leaves exactly three bytes for the documented ASCII ellipsis (#1132). + expected=(full[:252].decode('utf-8', errors='ignore')+'...').encode('utf-8') if clipped else full + assert message.encode('utf-8')==expected, 'E000 message bytes differ' + +def main(): + binary=str(Path(sys.argv[1]).resolve()) + # Resolve the binary's supplied directory entry without following a hard + # link into build/: callers supply the normal src/eigenscript alias. + cases=[('short','missing.eigs',False),('ascii','a'*190+'/missing.eigs',True), + ('multibyte','é'*110+'/missing.eigs',True)] + completed=[] + with tempfile.TemporaryDirectory(prefix='e000-') as root: + for name,suffix,clipped in cases: + # A fixed long parent makes the cut reproducible on Linux too. + prefix='' if name=='short' else 'parent-'+'p'*80+'/' + path=str(Path(root)/(prefix+suffix)) + assert not Path(path).exists() + p=subprocess.run([binary,'--lint','--json',path],capture_output=True,timeout=10) + assert p.returncode==1 and p.stderr==b'', (name,'E000 process',p.returncode,p.stderr) + validate(p.stdout,path,clipped) + completed.append(name) + assert completed==['short','ascii','multibyte'], 'E000 case population changed' + print(f'E000: {len(completed)} cases passed') +if __name__=='__main__':main() diff --git a/tools/lint_message_utf8_check.sh b/tools/lint_message_utf8_check.sh index 008b1118..6315c888 100755 --- a/tools/lint_message_utf8_check.sh +++ b/tools/lint_message_utf8_check.sh @@ -202,6 +202,14 @@ out="$(decode "$TMP/e000.json" E000)"; rc=$? checked=$((checked + 1)) if [ $rc -ne 0 ]; then bad "E000: $out"; else note " ok E000 ($out)"; fi fixture_codes="$fixture_codes E000" +# Dedicated paths pin the message bound independently of host TMPDIR length. +e000_out="$(python3 "$ROOT/tools/lint_e000_check.py" "$EIGS" 2>&1)"; e000_rc=$? +checked=$((checked + 1)) +if [ "$e000_rc" -ne 0 ] || [ "$e000_out" != "E000: 3 cases passed" ]; then + bad "E000 path contract: $e000_out" +else + note " ok E000 path contract ($e000_out)" +fi # --- 3. registry, both directions ------------------------------------------ doc_codes="$(grep -oE '^\| *`[WE]0[0-9][0-9]`' "$DOCS" | grep -oE '[WE]0[0-9][0-9]' | sort -u | tr '\n' ' ')" @@ -373,7 +381,7 @@ fi # --- verdict --------------------------------------------------------------- # Floor, not an exact count: adding a rule (and its fixture) raises it, and # only REMOVING coverage needs an edit here. -FLOOR=39 +FLOOR=40 if [ "$checked" -lt "$FLOOR" ]; then bad "only $checked checks ran, floor is $FLOOR — coverage was removed" fi From ee0cb8ee460137a92661c6396a261be0580b14cd Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 12 Sep 2026 02:15:57 -0500 Subject: [PATCH 5/9] test: preserve useful context when a suite child fails Show the first five explicit assertion lines and the last twenty captured lines for failed EigenScript and lint children. Expected parser diagnostics previously hid the later assertion. Consume the existing capture without rerunning the child or changing its verdict, and preserve complete lines. Fail setup if the formatter dependency is missing. Validation: formatter 3 positive cases and 2 attributed fault rejections; shell syntax and git diff checks passed. Full suite execution remains CI work. Evidence: /tmp/ems-close-20260911/failure-output-controls/result-r1.json SHA256: 326c1e3c0c9e06411d49740461fc39e4bf68d8c62ea717219a7466f59dd98282 --- tests/failure_output.sh | 20 ++++++++++++++++++++ tests/run_all_tests.sh | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/failure_output.sh diff --git a/tests/failure_output.sh b/tests/failure_output.sh new file mode 100644 index 00000000..98bc0e6e --- /dev/null +++ b/tests/failure_output.sh @@ -0,0 +1,20 @@ +# Display only: consume an already captured failure, never decide its verdict. +# Read all input so an early reader exit cannot break the producer's pipeline. +eigs_failure_output() { + LC_ALL=C awk ' + { + tail[(NR - 1) % 20] = $0 + if ($0 ~ /^[[:space:]]*(FAIL:|MISMATCH:|AssertionError)/) { + matches++ + if (shown < 5) assertions[++shown] = $0 + } + } + END { + print " --- assertion excerpts (first 5; complete lines) ---" + for (i = 1; i <= shown; i++) print assertions[i] + print " --- captured tail (last 20 complete lines) ---" + first = NR > 20 ? NR - 19 : 1 + for (i = first; i <= NR; i++) print tail[(i - 1) % 20] + printf " --- captured %d lines; %d assertion matches; displayed %d assertions ---\n", NR, matches, shown + }' +} diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index cb93a866..b3a09884 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -6,6 +6,7 @@ # whole suite. TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" export EIGS_TEST_DIR="$TESTS_DIR" +. "$TESTS_DIR/failure_output.sh" || exit 1 cd "$(dirname "$0")/../src" || { echo "cannot cd to src"; exit 1; } PASS=0 @@ -424,7 +425,7 @@ check_eigs_suite() { else FAIL=$((FAIL + n)) echo " FAIL: $test_name (rc=$rc)" - echo "$out" | grep -iE "FAIL|MISMATCH|assert|error" | head -5 + printf '%s\n' "$out" | eigs_failure_output fi } @@ -5220,7 +5221,7 @@ PASS=$((PASS + LINT_PASS)) FAIL=$((FAIL + LINT_FAIL)) if [ "$LINT_FAIL" -gt 0 ]; then echo " FAIL: $LINT_FAIL linter check(s) failed" - echo "$LINT_OUTPUT" | grep "FAIL:" | head -5 + printf '%s\n' "$LINT_OUTPUT" | eigs_failure_output else echo " PASS: all $LINT_PASS linter checks" fi From affc5a5652e17e0c55343c927ef903d3f2c745a2 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 12 Sep 2026 02:15:57 -0500 Subject: [PATCH 6/9] fix(ci): give both sanitizer suites independent workers Core ASan plus LSP and HTTP/model ASan now own separate checkouts and retain the original 45-minute limits. Keep the existing required status as an always aggregate that requires both workers and a successful scope check. The prior job passed core 5060/5060 and LSP 110/110, then exhausted its cumulative budget during the second suite. No checks or sanitizer options are removed, and no incomplete suite is accepted. Validation: exact preservation of all 4 original non-checkout steps; dropped and duplicated suite controls rejected; 15 actual aggregate-shell cases passed, including rejection of failure, cancellation, skipped or missing workers and unknown scope output. Full remote completion remains required. Evidence: /tmp/ems-close-20260911/sanitizer-split-review/result-r1.json SHA256: d663ca4fdd2925f908cebddec29c691de53fc289a02ad4be4d50fa21fafffd4f --- .github/workflows/ci.yml | 53 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58079a44..71a17c82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -587,8 +587,35 @@ jobs: # The net that catches use-after-free, buffer overflow, and UB the normal # -O2 build silently tolerates. Per-iteration leak regressions are covered # separately by tests/test_leak_guard.sh. + # Keep the required status while both complete sanitizer variants run in + # independent checkouts. Docs-only workers skip code steps but must succeed. sanitizers: name: asan + ubsan (full suite) + needs: [scope, sanitizers-core, sanitizers-http] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Require complete sanitizer coverage + shell: bash + env: + SCOPE_RESULT: ${{ needs.scope.result }} + CODE: ${{ needs.scope.outputs.code }} + CORE_RESULT: ${{ needs.sanitizers-core.result }} + HTTP_RESULT: ${{ needs.sanitizers-http.result }} + run: | + set -euo pipefail + [ "$SCOPE_RESULT" = success ] + case "$CODE" in true|false) ;; *) exit 1;; esac + for result in "$CORE_RESULT" "$HTTP_RESULT"; do + [ "$result" = success ] || exit 1 + done + echo "Sanitizer coverage complete (code=$CODE; two workers succeeded)" + + sanitizers-core: + name: asan + ubsan / core and LSP needs: [dev-image, scope] runs-on: ubuntu-latest # 45, not 30: the green run before #915's [99u] section landed took 19m57, @@ -631,6 +658,29 @@ jobs: # sanitizer report from the LSP process (leaks, UB). run: cd tests && bash test_lsp_asan.sh + sanitizers-http: + name: asan + ubsan / HTTP and model full suite + needs: [dev-image, scope] + runs-on: ubuntu-latest + # 45, not 30: the green run before #915's [99u] section landed took 19m57, + # and that section legitimately adds minutes under ASan (it launches the + # sanitized binary ~60 times; sanitizer process startup dominates) — a slow + # runner then hit the old ceiling and the job rendered as CANCELLED at + # 30:02 with the suite mid-section. A timeout is not a verdict: the same + # tree's local ASan suite was 4117/4117. Headroom target ~2x the observed + # green duration, per the suite-runtime-baseline rule. + timeout-minutes: 45 + permissions: + contents: read + packages: read + container: + image: ${{ needs.dev-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The extension surface under sanitizers. `make asan` above compiles the # HTTP + model extensions OUT, so ext_http.c — a network-facing server # and client, the most exposed code in the repo — was never sanitized @@ -647,8 +697,7 @@ jobs: # path (#731) are therefore NOT caught by this step; that class needs an # RSS-growth gate, measured separately. # - # Runs last in this job: every Makefile variant writes src/eigenscript, - # so this must not clobber the binary the steps above are using. + # This worker owns its checkout and alias, independently of core ASan. - if: needs.scope.outputs.code == 'true' name: Run suite under sanitizers with the HTTP+model extensions env: From bb49292b40e5d5e6b4ebb5d13410e033e59cb0bf Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 12 Sep 2026 06:49:34 -0500 Subject: [PATCH 7/9] fix: repair macOS executable paths, matrix results, and CI gates Resolve and own the executable path before chdir, use separate rounded matrix multiply/add operations, and preserve the pinned buffer NaN result. Use upstream LLVM for macOS gfx leak checks and retain invalid-path lint coverage on filesystems that reject invalid UTF-8 names. Measured gate results: EXE PATH: 5 passed, 0 failed (native and no-proc); old no-proc: 1/5. STRICT: 103 passed, 0 failed. identical-when-off: 99 differing: 0 raises-under-strict: 99 silent: 0 misattributed: 0 answer-pins held: 31 broken: 0 valid-input rows unchanged in BOTH modes: 170 / 170 UTF-8: 40 checks, 30 documented codes, 8 pinned exemptions; 2000 identifier runs and 1125 source-byte runs. Results: 247 passed, 0 failed, 247 total (lint). Doc examples: 88 checked, 88 passed, 0 failed, 5 skipped. Changed compiler audit: 4/4 and recognizer coverage passed; compiler-audit integration and synthetic fault selftests: OK. gfx startup controls: 2/2; original wrongly 15/15, fixed rejects startup. gfx symbol controls: 3/3; independent corpus decisions: 20/20. Independent runtime and gate reviews: PASS within local scope. Full integrated platform and sanitizer CI pending; no native macOS pass is claimed from the local controls. --- .github/workflows/ci.yml | 15 ++++ CHANGELOG.md | 8 ++ docs/COMPARISON.md | 3 + docs/SPEC.md | 11 ++- src/builtins.c | 8 +- src/builtins_host.c | 16 +--- src/builtins_internal.h | 7 +- src/builtins_tensor.c | 65 +++++++++------- src/eigenscript.h | 3 + src/fsutil.c | 53 +++++++++++++ src/fsutil.h | 3 + src/main.c | 32 ++------ src/state.c | 5 ++ tests/run_all_tests.sh | 14 ++++ tests/test_asan_gfx.sh | 97 +++++++++++++++++------- tests/test_exe_path.py | 75 +++++++++++++++++++ tests/test_lint.sh | 19 ++++- tests/test_strict_math.sh | 73 +++++++++++++++++- tools/lint_message_utf8_check.sh | 9 ++- tools/lint_path_fixture.py | 33 ++++++++ tools/lint_source_byte_sweep.py | 124 ++++++++++++++++++++++++------- 21 files changed, 532 insertions(+), 141 deletions(-) create mode 100644 tests/test_exe_path.py create mode 100644 tools/lint_path_fixture.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71a17c82..c258db4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,6 +328,21 @@ jobs: name: Verify binary run: ./src/eigenscript --version + # Use upstream LLVM's LSan runtime for the gfx gate, independently + # of the default Apple Clang used for the interpreter build. + # Its executable control must start and its intentional leak must fire. + - if: needs.scope.outputs.code == 'true' + name: Select gfx LeakSanitizer toolchain + run: | + case '${{ matrix.os }}' in + macos-15-intel) formula=llvm@18 ;; + macos-latest) formula=llvm@20 ;; + esac + sanitizer_cc="$(brew --prefix "$formula")/bin/clang" + test -x "$sanitizer_cc" + "$sanitizer_cc" --version + echo "EIGS_ASAN_GFX_CC=$sanitizer_cc" >> "$GITHUB_ENV" + - if: needs.scope.outputs.code == 'true' name: Run test suite run: cd tests && bash run_all_tests.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 17fd394a..0b96c0fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,14 @@ All notable changes to EigenScript are documented here. ### Fixed +- Standard-library imports and `exe_path` retain an absolute executable + anchor after `chdir` on macOS, including relative and PATH launches (#1133). +- Matrix products use separate binary64 multiplication and addition across + platforms, preserving strict-mode invalid-result detection (#1131). +- CI completes extension and sanitizer variants in separate workers. macOS + harnesses handle filename rejection, use an LLVM leak-detection runtime, + and reject sanitizer startup failures (#1126). + - **`EIGS_STRICT=1` reaches the graphics and audio extension (#1007).** `src/ext_gfx.c` had no raise path at all — `grep -c rt_error src/ext_gfx.c` was 0 — while ~89 argument reads went straight through `items[N]->data.num`, diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 55db515d..325f1317 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -203,6 +203,9 @@ list `buffer of [rows, cols]` and `reshape of [buf, rows, cols]` build the flat-backed matrix. `matmul`, `softmax`, `sum`, `mean`, `norm`, `gather` and the rest accept either, and hand back a buffer when every operand was one. +`matmul`, `matmul_at`, and `matmul_bt` round each multiplication to binary64 +before adding it to the accumulator, in ascending inner-index order, on both +containers. They do not fuse the multiplication and addition. One place EigenScript is louder than NumPy: an out-of-range index in `gather` **raises** rather than answering a stand-in, on both containers — NumPy's diff --git a/docs/SPEC.md b/docs/SPEC.md index cc253cfe..50bf58c7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -281,9 +281,9 @@ consequences are contracts you can rely on: raises as `arithmetic`. The JIT bails to the interpreter on a non-finite result, so both tiers raise from the same guard. One default-path asymmetry is older than strict mode and is left alone by it: a `matmul` - whose result is a **buffer** keeps the raw `NaN` the kernel wrote (it - reads back as `null`, and `math_flags` is not set), where a list result - collapses to `0` — strict raises on both. + whose result is a **buffer** preserves a `NaN` sentinel consistently + across platforms (it reads back as `null`, and `math_flags` is not set), + where a list result collapses to `0` — strict raises on both. - **JSON parse failure in `json_path`.** With the flag off a malformed document is walked leniently and a parse failure answers the same `""` an absent key does. Under strict `json_path` applies `json_decode`'s @@ -2059,7 +2059,10 @@ The tensor builtins operate directly on the flat data — no per-call conversion `matmul of [a, b]` multiplies two shaped buffers (a 1-D buffer is a row vector, so `matmul of [vec, mat]` returns a 1-D result); `matmul_at` / `matmul_bt` multiply with the first / second operand transposed (`aᵀ·b`, `a·bᵀ`) without -materialising the transpose; `add`, `subtract`, `multiply`, `divide` are +materialising the transpose. All three matrix products round each +multiplication to binary64 before adding it to the accumulator, in ascending +inner-index order; multiplication and addition are not fused. +`add`, `subtract`, `multiply`, `divide` are elementwise, with a `[cols]` buffer broadcast over the rows of a `[rows × cols]` buffer and a number broadcast over every element; `relu`, `leaky_relu`, `softmax`, `log_softmax`, `sum`, `mean`, `norm`, `gather` diff --git a/src/builtins.c b/src/builtins.c index b788bdbb..c538c13b 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -2366,11 +2366,9 @@ Value* builtin_seed_random(Value *arg) { } -/* ---- Command-line arguments ---- - * Non-static: builtins_host.c's exe_path uses g_argv[0] as its fallback - * when /proc/self/exe is unavailable (declared in builtins_internal.h). */ -int g_argc = 0; -char **g_argv = NULL; +/* ---- Command-line arguments ---- */ +static int g_argc = 0; +static char **g_argv = NULL; void eigenscript_set_args(int argc, char **argv) { g_argc = argc; diff --git a/src/builtins_host.c b/src/builtins_host.c index 3dfc6dc5..3c1b7277 100644 --- a/src/builtins_host.c +++ b/src/builtins_host.c @@ -377,22 +377,14 @@ Value* builtin_getcwd(Value *arg) { /* exe_path of null → absolute path of the running interpreter binary. * Lets an EigenScript program re-invoke the same interpreter — e.g. a * test runner spawning `exec_capture of [exe_path of null, testfile]`, - * which is more robust than assuming `eigenscript` is on PATH. Reads - * /proc/self/exe; falls back to argv[0]. */ + * which is more robust than assuming `eigenscript` is on PATH. The state + * captures the host path before script code can change the cwd. */ Value* builtin_exe_path(Value *arg) { (void)arg; /* #585: the interpreter path is machine-dependent — taped so replay - * serves the recorded path without touching /proc/self/exe. */ + * serves the recorded path without consulting the host anchor. */ TRACE_NONDET_TAKE("exe_path"); - char buf[4096]; - ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); - if (n > 0 && n < (ssize_t)sizeof(buf)) { - buf[n] = '\0'; - TRACE_NONDET_RECORD("exe_path", make_str(buf)); - } - if (g_argv && g_argc > 0 && g_argv[0]) - TRACE_NONDET_RECORD("exe_path", make_str(g_argv[0])); - TRACE_NONDET_RECORD("exe_path", make_str("eigenscript")); + TRACE_NONDET_RECORD("exe_path", make_str(g_exe_path ? g_exe_path : "eigenscript")); } /* chdir of "path" → 1 on success, 0 on failure */ diff --git a/src/builtins_internal.h b/src/builtins_internal.h index f3f7098f..0610b586 100644 --- a/src/builtins_internal.h +++ b/src/builtins_internal.h @@ -79,11 +79,8 @@ void register_host_builtins(Env *env); * "fail loudly under EIGS_REPLAY" boundary check (channels use it too). */ int replay_blocks(const char *fn); -/* builtins.c — shared with builtins_host.c: the process argv snapshot - * (exe_path's argv[0] fallback) and the "is this name the language's?" - * predicate (build_corpus skips registered builtins). */ -extern int g_argc; -extern char **g_argv; +/* builtins.c — the "is this name the language's?" predicate shared with + * builtins_host.c (build_corpus skips registered builtins). */ int eigs_is_registered_builtin(const char *name); #endif /* EIGENSCRIPT_BUILTINS_INTERNAL_H */ diff --git a/src/builtins_tensor.c b/src/builtins_tensor.c index 6b988ff2..8e3739ef 100644 --- a/src/builtins_tensor.c +++ b/src/builtins_tensor.c @@ -50,11 +50,25 @@ void ne_softmax_buf(double *data, int64_t rows, int64_t cols) { } } +/* #1131: each product must round to binary64 before accumulation. Fusing + * the multiply-add changes finite rounding and turns inf + (-inf) into inf + * when the second product would overflow, bypassing matmul's NaN guard. + * Keep this policy at the shared kernels for every build/storage road and + * all three transpose forms. GCC uses scoped optimization options; Clang's + * contract pragma is scoped to each function body below. */ +#if defined(__GNUC__) && !defined(__clang__) +#pragma GCC push_options +#pragma GCC optimize ("fp-contract=off") +#endif + void ne_matmul_buf( double *a, int64_t m, int64_t k, double *b, int64_t n, double *out ) { +#if defined(__clang__) +#pragma clang fp contract(off) +#endif memset(out, 0, m * n * sizeof(double)); for (int64_t i0 = 0; i0 < m; i0 += NE_TENSOR_TILE_SIZE) { for (int64_t j0 = 0; j0 < n; j0 += NE_TENSOR_TILE_SIZE) { @@ -89,6 +103,9 @@ void ne_matmul_at_buf( double *b, int64_t n, double *out ) { +#if defined(__clang__) +#pragma clang fp contract(off) +#endif memset(out, 0, k * n * sizeof(double)); for (int64_t i0 = 0; i0 < k; i0 += NE_TENSOR_TILE_SIZE) { for (int64_t j0 = 0; j0 < n; j0 += NE_TENSOR_TILE_SIZE) { @@ -115,6 +132,9 @@ void ne_matmul_bt_buf( double *b, int64_t n, double *out ) { +#if defined(__clang__) +#pragma clang fp contract(off) +#endif memset(out, 0, m * n * sizeof(double)); for (int64_t i0 = 0; i0 < m; i0 += NE_TENSOR_TILE_SIZE) { for (int64_t j0 = 0; j0 < n; j0 += NE_TENSOR_TILE_SIZE) { @@ -135,6 +155,10 @@ void ne_matmul_bt_buf( } } +#if defined(__GNUC__) && !defined(__clang__) +#pragma GCC pop_options +#endif + /* ---- flat-buffer tensors ------------------------------------------------- * A VAL_BUFFER carries an optional 2-D shape (rows/cols; rows==0 => 1-D, length * = count). The tensor builtins gain a fast path that computes directly on the @@ -651,37 +675,24 @@ Value* builtin_tensor_matmul(Value *arg) { : make_shaped_buffer(ar, bc); if (!res) return make_null(); ne_matmul_buf(a->data.buffer.data, ar, ac, b->data.buffer.data, bc, res->data.buffer.data); - /* #971: the kernel accumulates raw, so inf - inf leaves a NaN in the - * result buffer. The boxed roads collapse a NaN in make_num's - * num_guard; this path stores it verbatim, and a raw - * NaN in a buffer is not a number the program can see — its bit - * pattern is a NaN-boxed slot tag, so `r[i]` reads back as `null` - * (0xFFF8... is SLOT_NULL_BITS). - * - * Under strict that undefined result RAISES, named, like every - * other enumerated NaN source. With the flag OFF the buffer is - * left exactly as the kernel wrote it, INCLUDING that NaN: this - * reform's whole safety claim is that the default path is - * byte-identical to the previous release, and collapsing here - * would change `r[0]` from `null` to 0 and set EIGS_MATH_INVALID - * where the release set nothing (measured against the v0.43.0 - * binary). The `null` read is a real defect — it is a buffer - * element that is neither a number nor a program-made null — but - * it is a PRE-EXISTING one, it is not unique to this writer - * (ext_store round-trips a NaN buffer element deliberately — - * store_nonfinite_sentinel), and fixing it means fixing the READ - * for every road at once. That is its own change with its own - * differential; it is recorded in ROADMAP.md, not smuggled in - * under a strict-mode flag. STRICT_DOMAIN is the shape for that: - * it raises under strict and does nothing otherwise, so the soft - * path cannot drift. */ - if (g_strict) { - for (int i = 0; i < res->data.buffer.count; i++) - if (res->data.buffer.data[i] != res->data.buffer.data[i]) { + /* #971/#1131: inf - inf leaves a raw NaN. Strict raises; default + * preserves the historical buffer sentinel (`r[i]` is null, with + * no math flag), unlike the list path's 0 + invalid. x86 generates + * the negative quiet NaN that matches SLOT_NULL_BITS, but ARM's + * positive NaN would instead read as 0 + invalid. Canonicalize only + * this result's NaNs to the legacy sentinel, preserving that + * documented default on both architectures. Do not use num_guard: + * changing buffer-NaN reads generally is a separate reform tracked + * in ROADMAP.md. Finite results and infinities remain untouched. */ + for (int i = 0; i < res->data.buffer.count; i++) { + if (res->data.buffer.data[i] != res->data.buffer.data[i]) { + if (g_strict) { STRICT_DOMAIN(1, "matmul", "result is not a number (NaN has no defined value)"); break; } + res->data.buffer.data[i] = slot_null().d; + } } return res; } diff --git a/src/eigenscript.h b/src/eigenscript.h index 21e60644..6828c4c1 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -657,6 +657,8 @@ struct EigsState { /* Filesystem anchors for `import` / `load_file` resolution. */ char script_dir[4096]; char exe_dir[4096]; + /* Heap-owned absolute executable anchor, immutable after CLI startup. */ + char *exe_path; /* Import-time module cache — populated on first import of a path, * read on subsequent imports of the same path. Single-writer in * practice (main thread imports at startup); no internal lock. */ @@ -1154,6 +1156,7 @@ extern __thread EigsThread *eigs_current; #define g_global_env (eigs_current->state->global_env) #define g_script_dir (eigs_current->state->script_dir) #define g_exe_dir (eigs_current->state->exe_dir) +#define g_exe_path (eigs_current->state->exe_path) #define g_load_env (eigs_current->load_env) #define g_compile_module_boundary (eigs_current->compile_module_boundary) #define g_compile_import_toplevel (eigs_current->compile_import_toplevel) diff --git a/src/fsutil.c b/src/fsutil.c index 58eaf5ff..973b09b6 100644 --- a/src/fsutil.c +++ b/src/fsutil.c @@ -48,6 +48,59 @@ int eigs_import_resolve(const char *base, const char *name, #include #include #include +#if defined(__APPLE__) +#include +#endif + +/* Resolve once at state/CLI startup. In particular dyld's answer may contain + * a symlink or relative components, so it must be canonicalized before chdir. + * No state bridge macros here: embedders call this before thread attachment. */ +char *eigs_executable_path(const char *argv0) { +#if defined(__APPLE__) + uint32_t capacity = 0; + (void)_NSGetExecutablePath(NULL, &capacity); + if (capacity) { + char *path = xmalloc(capacity); + int rc = _NSGetExecutablePath(path, &capacity); + char *absolute = rc == 0 ? realpath(path, NULL) : NULL; + free(path); + if (absolute) return absolute; + } +#elif defined(__linux__) + char path[4096]; + ssize_t n = readlink("/proc/self/exe", path, sizeof(path)); + /* A full buffer is truncated, not a usable executable path. */ + if (n > 0 && n < (ssize_t)sizeof(path) && path[0] == '/') { + path[n] = '\0'; + return xstrdup(path); + } +#endif + if (!argv0 || !*argv0) return NULL; + if (strchr(argv0, '/')) return realpath(argv0, NULL); + + /* A bare argv[0] names a PATH lookup, not a file in the startup cwd. + * Empty/relative entries are interpreted now, while that cwd is intact. */ + const char *entry = getenv("PATH"); + if (!entry) entry = "/bin:/usr/bin"; + size_t name_len = strlen(argv0); + for (;;) { + const char *colon = strchr(entry, ':'); + size_t dir_len = colon ? (size_t)(colon - entry) : strlen(entry); + char *candidate = xmalloc(dir_len + name_len + 2); + memcpy(candidate, entry, dir_len); + size_t offset = dir_len; + if (dir_len) candidate[offset++] = '/'; + memcpy(candidate + offset, argv0, name_len + 1); + struct stat st; + char *absolute = NULL; + if (access(candidate, X_OK) == 0 && stat(candidate, &st) == 0 && S_ISREG(st.st_mode)) + absolute = realpath(candidate, NULL); + free(candidate); + if (absolute) return absolute; + if (!colon) return NULL; + entry = colon + 1; + } +} /* File I/O helper — used by load_file and main() */ char* read_file_util(const char *path, long *out_size) { diff --git a/src/fsutil.h b/src/fsutil.h index 7eb5292b..493a47bd 100644 --- a/src/fsutil.h +++ b/src/fsutil.h @@ -30,6 +30,9 @@ char* read_file_util(const char *path, long *out_size); /* Canonical containing directory of `path`. Hosted; caller frees. */ char *eigs_file_directory(const char *path); +/* Absolute executable path, captured before user chdir. Caller frees; NULL + * if neither the platform lookup nor the startup argv/PATH fallback resolves. */ +char *eigs_executable_path(const char *argv0); /* Raise EK_IO naming every root the chain tried. Hosted. */ void eigs_file_resolve_error(const char *operation, const char *base, const char *path, int line); diff --git a/src/main.c b/src/main.c index aabad033..56b42d32 100644 --- a/src/main.c +++ b/src/main.c @@ -24,30 +24,14 @@ extern int eigs_bundle_create(const char *argv0, const char *script, /* The REPL (piped loop + the #392 interactive line editor) lives in repl.c. */ static void set_exe_dir(const char *argv0) { - char exe_path[4096]; - ssize_t n = readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1); - if (n > 0 && n < (ssize_t)sizeof(exe_path)) { - exe_path[n] = '\0'; - } else if (argv0 && strchr(argv0, '/')) { - strncpy(exe_path, argv0, sizeof(exe_path) - 1); - exe_path[sizeof(exe_path) - 1] = '\0'; - } else { - memcpy(g_exe_dir, ".", 2); - return; - } - - const char *last_slash = strrchr(exe_path, '/'); - if (!last_slash) { - memcpy(g_exe_dir, ".", 2); - return; - } - int dir_len = (int)(last_slash - exe_path); - if (dir_len <= 0) { - memcpy(g_exe_dir, "/", 2); - return; - } - if (dir_len >= (int)sizeof(g_exe_dir)) dir_len = sizeof(g_exe_dir) - 1; - memcpy(g_exe_dir, exe_path, dir_len); + if (!g_exe_path) g_exe_path = eigs_executable_path(argv0); + if (!g_exe_path) return; + const char *last_slash = strrchr(g_exe_path, '/'); + if (!last_slash) return; + size_t dir_len = last_slash == g_exe_path ? 1 : (size_t)(last_slash - g_exe_path); + /* Never turn an overlong anchor into a different, truncated directory. */ + if (dir_len >= sizeof(g_exe_dir)) return; + memcpy(g_exe_dir, g_exe_path, dir_len); g_exe_dir[dir_len] = '\0'; } diff --git a/src/state.c b/src/state.c index b626eeb1..f5dd6ee8 100644 --- a/src/state.c +++ b/src/state.c @@ -4,6 +4,7 @@ #include "eigenscript.h" #include "env_flag.h" #include "state.h" +#include "fsutil.h" #include "vm.h" #include "jit.h" #include "trace.h" /* #739: trace_thread_release on detach */ @@ -42,6 +43,9 @@ EigsState *eigs_state_new(void) { /* Filesystem anchor defaults; main/eigenlsp overwrite after attach. */ st->script_dir[0] = '.'; st->script_dir[1] = '\0'; st->exe_dir[0] = '.'; st->exe_dir[1] = '\0'; +#if !EIGENSCRIPT_FREESTANDING + st->exe_path = eigs_executable_path(NULL); +#endif /* Phase 9: JIT tuning per state, read once from env at creation. */ jit_state_init_thresholds(st); return st; @@ -69,6 +73,7 @@ void eigs_state_destroy(EigsState *st) { * paired with a leave — but free defensively). */ for (size_t i = 0; i < st->loading_count; i++) free(st->loading_stack[i]); free(st->loading_stack); + free(st->exe_path); /* #307: value-candidate buffer pins were drained at gc_collect_at_exit; * free the (now-empty) backing array. NULL if no cycle ever parked. */ free(st->gc_val_buf); diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index b3a09884..05229a83 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -5271,6 +5271,20 @@ else fi echo "" +# [81c] Executable-relative imports survive relative/absolute/PATH/symlink launches. +echo "[81c] Executable path and import anchors" +EXEPATH_OUTPUT=$(python3 "$TESTS_DIR/test_exe_path.py" &1); EXEPATH_RC=$? +TOTAL=$((TOTAL + 5)) +if rc_ok "$EXEPATH_RC" "$EXEPATH_OUTPUT" && echo "$EXEPATH_OUTPUT" | grep -q '^EXE PATH: 5 passed, 0 failed$'; then + PASS=$((PASS + 5)) + echo " PASS: all 5 executable-path launch forms" +else + FAIL=$((FAIL + 5)) + echo " FAIL: executable-path tests (rc=$EXEPATH_RC)" + echo "$EXEPATH_OUTPUT" +fi +echo "" + # [82] JIT fast paths — checksummed correctness for the fused opcodes, # inline ICs, iter/native-call helpers, and OSR that only fire on hot # benchmark-shaped code. Runs with EIGS_JIT_STATS so we can also assert diff --git a/tests/test_asan_gfx.sh b/tests/test_asan_gfx.sh index ea197fe1..0adb6511 100755 --- a/tests/test_asan_gfx.sh +++ b/tests/test_asan_gfx.sh @@ -27,8 +27,9 @@ # looking at the corpus at all, because a corpus verdict from a blind # instrument is not evidence. # -# SKIPS CLEANLY when the toolchain has no ASan, when the source list cannot -# be derived, or when the binary it ends up with has no gfx builtins. libSDL2 +# SKIPS CLEANLY when the default toolchain has no ASan or when the binary +# has no gfx builtins. Explicit compiler selection and build failures are +# errors. libSDL2 # is NOT required: it is dlopen'd, so the corpus runs either way -- gfx_open # answers 0 and the drawing calls no-op, which still walks every allocation # path on the argument side. Whether SDL was present is reported, so a green @@ -39,6 +40,7 @@ set -u TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$TESTS_DIR/.." && pwd)" CORPUS="$TESTS_DIR/gfx_asan_corpus" +. "$TESTS_DIR/lsan_classify.sh" || exit 1 PASS=0; FAIL=0 ok() { echo " PASS: $1"; PASS=$((PASS+1)); } @@ -51,15 +53,29 @@ TMO="" if command -v timeout >/dev/null 2>&1; then TMO="timeout 120" elif command -v gtimeout >/dev/null 2>&1; then TMO="gtimeout 120"; fi -# The -Werror trio is spelled out on every gcc line below rather than folded +# The -Werror trio is spelled out on every compiler line below rather than folded # into a variable: tools/werror_switch_check.sh reads the line, not the # expansion, and this script is enrolled in its SCRIPT_AUDITS with a floor of # four compile invocations. +CC="${EIGS_ASAN_GFX_CC:-gcc}" ASAN_CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1" -if ! echo 'int main(void){return 0;}' | gcc -Werror=switch -Werror=comment -Werror=misleading-indentation $ASAN_CFLAGS -x c - -o /tmp/eigs_asan_gfx_probe 2>/dev/null; then +if ! echo 'int main(void){return 0;}' | "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $ASAN_CFLAGS -x c - -o /tmp/eigs_asan_gfx_probe 2>/tmp/eigs_asan_gfx_probe.log; then rm -f /tmp/eigs_asan_gfx_probe + cat /tmp/eigs_asan_gfx_probe.log + if [ -n "${EIGS_ASAN_GFX_CC:-}" ]; then + bad "configured gfx sanitizer compiler cannot build the control: $CC" + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 + fi skip "AddressSanitizer not available in this toolchain" fi +if ! ASAN_OPTIONS=detect_leaks=1 /tmp/eigs_asan_gfx_probe > /tmp/eigs_asan_gfx_probe.log 2>&1; then + cat /tmp/eigs_asan_gfx_probe.log + rm -f /tmp/eigs_asan_gfx_probe + bad "gfx sanitizer runtime cannot start with leak detection; set EIGS_ASAN_GFX_CC to a compiler with LeakSanitizer" + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 +fi rm -f /tmp/eigs_asan_gfx_probe export ASAN_OPTIONS="detect_leaks=1:abort_on_error=0" @@ -75,7 +91,8 @@ BIN="" if [ -n "${EIGS_ASAN_GFX:-}" ] && [ -x "${EIGS_ASAN_GFX}" ]; then BIN="$EIGS_ASAN_GFX" echo " using EIGS_ASAN_GFX=$BIN" -elif [ -x "$ROOT/build/asan-gfx/eigenscript" ] \ +elif [ -z "${EIGS_ASAN_GFX_CC:-}" ] \ + && [ -x "$ROOT/build/asan-gfx/eigenscript" ] \ && [ -z "$(find "$ROOT/src" -name '*.c' -newer "$ROOT/build/asan-gfx/eigenscript" -print -quit 2>/dev/null)" ] \ && [ -z "$(find "$ROOT/src" -name '*.h' -newer "$ROOT/build/asan-gfx/eigenscript" -print -quit 2>/dev/null)" ]; then BIN="$ROOT/build/asan-gfx/eigenscript" @@ -86,12 +103,20 @@ else # fails the suite when the alias moves mid-run. Sources come from the # Makefile's own variable so a hand-copied list cannot drift (#223). SRCS=$(make -C "$ROOT" -s print-SRC_V_asan-gfx 2>/dev/null) - [ -n "$SRCS" ] || skip "could not read SRC_V_asan-gfx from the Makefile" - ( cd "$ROOT" && gcc -Werror=switch -Werror=comment -Werror=misleading-indentation $ASAN_CFLAGS \ + if [ -z "$SRCS" ]; then + bad "could not read SRC_V_asan-gfx from the Makefile" + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 + fi + if ! ( cd "$ROOT" && "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $ASAN_CFLAGS \ -DEIGENSCRIPT_EXT_HTTP=0 -DEIGENSCRIPT_EXT_MODEL=0 -DEIGENSCRIPT_EXT_DB=0 \ -DEIGENSCRIPT_EXT_GFX=1 '-DEIGENSCRIPT_VERSION="asan_gfx_gate"' \ - $SRCS -o /tmp/eigs_asan_gfx -lm -lpthread -ldl ) 2>/tmp/eigs_asan_gfx.log \ - || skip "asan-gfx build failed (see /tmp/eigs_asan_gfx.log)" + $SRCS -o /tmp/eigs_asan_gfx -lm -lpthread -ldl ) 2>/tmp/eigs_asan_gfx.log; then + cat /tmp/eigs_asan_gfx.log + bad "asan-gfx build failed" + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 + fi BIN=/tmp/eigs_asan_gfx echo " built /tmp/eigs_asan_gfx from SRC_V_asan-gfx" fi @@ -99,10 +124,11 @@ fi # ---------------------------------------------------------------- predicate # ONE predicate, used by the corpus rows AND by the controls. Two copies of # this decision is how a guard goes green while the production path regresses. -LAST_OUT="" +LAST_OUT=""; LAST_RC=0 leak_reported() { # -> 0 when a leak WAS reported LAST_OUT="$($TMO "$@" 2>&1)" - printf '%s' "$LAST_OUT" | grep -q "LeakSanitizer: detected memory leaks" + LAST_RC=$? + lsan_classify "$LAST_OUT" } # ------------------------------------------------------------- the controls @@ -112,10 +138,12 @@ CTRL_OK=1 # (0) The BINARY under test is an AddressSanitizer build. Decisive about # $BIN specifically, which the two program controls below are not: they # prove the toolchain and the predicate, not which binary was picked. -if command -v nm >/dev/null 2>&1 && nm -D "$BIN" 2>/dev/null | grep -q __asan; then +if command -v nm >/dev/null 2>&1 && nm "$BIN" 2>/dev/null | grep -q __asan; then ok "the binary under test links AddressSanitizer (__asan* present)" -elif strings "$BIN" 2>/dev/null | grep -q "AddressSanitizer"; then - ok "the binary under test links AddressSanitizer (banner string present)" +elif command -v nm >/dev/null 2>&1 && nm -D "$BIN" 2>/dev/null | grep -q __asan; then + # Stripped ELF binaries can retain dynamic ASan imports. Mach-O's nm + # rejects -D, so its ordinary symbol table is checked first above. + ok "the binary under test links AddressSanitizer (dynamic __asan* present)" else bad "the binary at $BIN carries no AddressSanitizer symbols — a clean corpus below would mean nothing" CTRL_OK=0 @@ -148,19 +176,22 @@ int main(void) { } CEOF CTRL_CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O0" -if gcc -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_leak.c -o /tmp/eigs_asan_gfx_leak 2>/dev/null \ -&& gcc -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_clean.c -o /tmp/eigs_asan_gfx_clean 2>/dev/null; then - if leak_reported /tmp/eigs_asan_gfx_leak; then +if "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_leak.c -o /tmp/eigs_asan_gfx_leak 2>/dev/null \ +&& "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_clean.c -o /tmp/eigs_asan_gfx_clean 2>/dev/null; then + if leak_reported /tmp/eigs_asan_gfx_leak && [ "$LAST_RC" -ne 0 ]; then ok "positive control: a deliberate 1234-byte leak IS reported" else bad "positive control: a deliberate leak was NOT reported — LeakSanitizer is not armed, so every corpus row below is a blind instrument" + printf '%s\n' "$LAST_OUT" CTRL_OK=0 fi - if leak_reported /tmp/eigs_asan_gfx_clean; then - bad "negative control: a leak-free program was reported as leaking — the predicate is always-red and proves nothing" - CTRL_OK=0 - else + leak_reported /tmp/eigs_asan_gfx_clean; CLEAN_CLASS=$? + if [ "$CLEAN_CLASS" -eq 2 ] && [ "$LAST_RC" -eq 0 ]; then ok "negative control: a leak-free program is clean" + else + bad "negative control: a leak-free program did not exit cleanly (rc=$LAST_RC class=$CLEAN_CLASS)" + printf '%s\n' "$LAST_OUT" + CTRL_OK=0 fi else bad "could not compile the leak controls; the corpus verdict would be unvalidated" @@ -180,10 +211,17 @@ fi # enters ext_gfx.c reports leak-free for the uninteresting reason. echo 'print of (gfx_text_width of ["m", 1])' > /tmp/eigs_asan_gfx_probe.eigs PROBE_OUT="$($TMO "$BIN" /tmp/eigs_asan_gfx_probe.eigs 2>&1)" +PROBE_RC=$? rm -f /tmp/eigs_asan_gfx_probe.eigs case "$PROBE_OUT" in *"undefined variable"*) skip "the binary at $BIN has no gfx builtins (not an EIGENSCRIPT_EXT_GFX build)" ;; esac +if [ "$PROBE_RC" -ne 0 ]; then + bad "gfx binary cannot execute the builtin probe (rc=$PROBE_RC)" + printf '%s\n' "$PROBE_OUT" + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 +fi N_FILES=0 for f in "$CORPUS"/*.eigs; do [ -f "$f" ] && N_FILES=$((N_FILES + 1)); done @@ -206,15 +244,18 @@ for f in "$CORPUS"/*.eigs; do else if leak_reported "$BIN" "$f"; then LEAK=1; else LEAK=0; fi fi - # UBSan findings ride the same stream and are just as much a defect. - UB=0 - printf '%s' "$LAST_OUT" | grep -q "runtime error:" && UB=1 - printf '%s' "$LAST_OUT" | grep -q "AddressSanitizer: \(heap\|stack\|global\|attempting\)" && UB=1 - if [ "$LEAK" = 0 ] && [ "$UB" = 0 ]; then + # Startup failures and signals cannot masquerade as clean corpus rows. + lsan_classify "$LAST_OUT"; CLASS=$? + RC_OK=0 + [ "$LAST_RC" -eq 0 ] && RC_OK=1 + if [ "$base" = 06_rejected.eigs ] && [ "$mode" = strict ] \ + && [ "$LAST_RC" -eq 1 ] \ + && printf '%s\n' "$LAST_OUT" | grep -Eq '^Error line [0-9]+: gfx_rect: expected \[number x, number y, number w, number h, number r, number g, number b\] and an optional number alpha$'; then RC_OK=1; fi + if [ "$LEAK" = 0 ] && [ "$CLASS" = 2 ] && [ "$RC_OK" = 1 ]; then ok "$base [$mode] clean under ASan+UBSan+LSan" else - bad "$base [$mode] leak=$LEAK sanitizer-error=$UB" - printf '%s\n' "$LAST_OUT" | grep -E "SUMMARY|runtime error:|ERROR: " | head -4 | sed 's/^/ /' + bad "$base [$mode] leak=$LEAK sanitizer-class=$CLASS rc=$LAST_RC" + printf '%s\n' "$LAST_OUT" | tail -8 | sed 's/^/ /' fi done done diff --git a/tests/test_exe_path.py b/tests/test_exe_path.py new file mode 100644 index 00000000..d2ece7b6 --- /dev/null +++ b/tests/test_exe_path.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Exercise the actual CLI's executable anchor across launch forms and chdir. + +Runs the host platform's resolver; macOS behavior is exercised on macOS CI. +""" +import json +import os +from pathlib import Path +import subprocess +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +BINARY = ROOT / "src" / "eigenscript" + + +def main(): + passed = failed = 0 + with tempfile.TemporaryDirectory(prefix="eigs-exe-path-") as temporary: + work = Path(temporary).resolve() + elsewhere = work / "elsewhere" + elsewhere.mkdir() + linked = work / "linked-eigenscript" + linked.symlink_to(BINARY) + program = f'''load_file of {json.dumps(str(ROOT / "lib" / "eigen.eigs"))} +print of ("before=" + (exe_path of null)) +old_cwd is getcwd of null +print of ("chdir=" + (str of (chdir of {json.dumps(str(elsewhere))}))) +print of ("cwd=" + (getcwd of null)) +print of ("after=" + (exe_path of null)) +print of ("meta=" + (str of (eigen_run of "import log\\nhas_key of [log, \\\"log_info\\\"]"))) +print of ("vm=" + (str of (eval of "import log\\nhas_key of [log, \\\"log_info\\\"]"))) +print of ("restore=" + (str of (chdir of old_cwd))) +print of ("done=" + (getcwd of null)) +''' + # subprocess's bare executable invokes PATH lookup, including a relative + # PATH component. No shell rewrites argv[0] into an absolute path. + cases = [ + ("relative", "./eigenscript", BINARY.parent, None), + ("absolute", str(BINARY), work, None), + ("PATH absolute", "eigenscript", work, str(BINARY.parent)), + ("PATH relative", "eigenscript", ROOT, "src"), + ("symlink", str(linked), work, None), + ] + for label, executable, cwd, path in cases: + env = os.environ.copy() + env.pop("EIGS_TRACE", None) + env.pop("EIGS_REPLAY", None) + if path is not None: + env["PATH"] = path + expected = [ + f"before={BINARY}", "chdir=1", f"cwd={elsewhere}", + f"after={BINARY}", "meta=1", "vm=1", "restore=1", + f"done={cwd}", + ] + try: + result = subprocess.run( + [executable, "-e", program], cwd=cwd, env=env, + text=True, capture_output=True, timeout=30, + ) + if result.returncode == 0 and result.stdout.splitlines() == expected and not result.stderr: + print(f"PASS: {label}: absolute stable path and both imports after chdir") + passed += 1 + else: + print(f"FAIL: {label}: rc={result.returncode}, stdout={result.stdout!r}, stderr={result.stderr!r}") + failed += 1 + except (OSError, subprocess.TimeoutExpired) as error: + print(f"FAIL: {label}: {error}") + failed += 1 + print(f"EXE PATH: {passed} passed, {failed} failed") + return int(failed != 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_lint.sh b/tests/test_lint.sh index 4bee9d8f..abdc4bda 100644 --- a/tests/test_lint.sh +++ b/tests/test_lint.sh @@ -2,7 +2,7 @@ # Test the EigenScript linter (--lint) set -e TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" -EIGS="$TESTS_DIR/../src/eigenscript" +EIGS="${EIGENSCRIPT_BIN:-$TESTS_DIR/../src/eigenscript}" # --- #1121: make a sanitizer diagnostic from ANY child visible to this file --- # Almost every assertion below captures the linter's text with `2>&1` and drops @@ -2438,10 +2438,21 @@ rm -f "$TMPFILE" # plain fprintf). A path is a byte string on POSIX, so both must sanitize it. PATHDIR=$(mktemp -d /tmp/lint_test_path_XXXXXX) BADPATH="$PATHDIR/$(printf 'w\xffname').eigs" -printf 'unused_local is 42\nprint of 1\n' > "$BADPATH" -check_json_utf8 "#1048 a file whose NAME is not valid UTF-8 decodes strictly" "$BADPATH" +PATH_STATE=$(python3 "$TESTS_DIR/../tools/lint_path_fixture.py" "$BADPATH") +case "$PATH_STATE" in + present|missing) ;; + *) echo " FAIL: unexpected filename fixture state: $PATH_STATE"; exit 1 ;; +esac +# A filesystem may reject the filename (the helper reports the actual errno). +# The invalid bytes still reach both diagnostic channels as a missing argv +# path, so these checks must run even when no readable fixture can exist. +check_json_utf8 "#1048 a file NAME with invalid UTF-8 decodes strictly ($PATH_STATE)" "$BADPATH" OUTPUT=$($EIGS --lint "$BADPATH" 2>&1 || true) -check_contains "#1048 the diagnostic still names the file it linted" "$OUTPUT" "w.*name.eigs:1: warning\[W001\]" +if [ "$PATH_STATE" = present ]; then + check_contains "#1048 the diagnostic still names the file it linted" "$OUTPUT" "w.*name.eigs:1: warning\[W001\]" +else + check_contains "#1048 the rejected filename still reaches the human diagnostic" "$OUTPUT" "cannot read file '.*w.*name.eigs'" +fi OUTPUT=$($EIGS --lint --json "${BADPATH}.missing" 2>/dev/null || true) check_contains "#1048 the unreadable-file payload (E000) still names the path" "$OUTPUT" '"code":"E000"' printf '%s' "$OUTPUT" | python3 -c 'import sys; sys.stdin.buffer.read().decode("utf-8")' 2>/dev/null \ diff --git a/tests/test_strict_math.sh b/tests/test_strict_math.sh index 87136cfd..63205e6d 100755 --- a/tests/test_strict_math.sh +++ b/tests/test_strict_math.sh @@ -224,8 +224,8 @@ run "SM49a default matmul(inf-inf) LIST path still collapses to 0 + invalid" uns 'local r is matmul of [[[1e200, 1e200]], [[1e200], [0 - 1e200]]] print of f"{r} {(math_flags of null).invalid}"' # The BUFFER path is deliberately NOT collapsed with the flag off. The kernel -# writes into the result buffer raw, so the NaN stays there, and a raw NaN in -# a buffer reads back as `null` (its bit pattern is a NaN-boxed slot tag, +# writes into the result buffer raw; its NaN is kept in the canonical legacy +# sentinel form, which reads back as `null` (a NaN-boxed slot tag, # 0xFFF8... == SLOT_NULL_BITS) with math_flags.invalid still 0. That is what # v0.43.0 does, and this change's contract is that the flag-off path is # byte-identical to it: an earlier draft collapsed it to 0 here and had to @@ -241,6 +241,21 @@ m2[0] is 1e200 m2[1] is 0 - 1e200 local r is matmul of [m1, m2] print of f"{r[0]} {(math_flags of null).invalid}"' +# #1131: canonicalization must visit every NaN and preserve intervening +# finite results. On ARM the kernel's invalid-operation NaNs are positive. +run "SM49c default matmul buffer preserves each NaN sentinel and finite neighbor" unset 0 "null 2e+200 null 0" \ +'local a is buffer of [1, 2] +a[0] is 1e200 +a[1] is 1e200 +local b is buffer of [2, 3] +b[0] is 1e200 +b[1] is 1 +b[2] is 1e200 +b[3] is 0 - 1e200 +b[4] is 1 +b[5] is 0 - 1e200 +local r is matmul of [a, b] +print of f"{r[0]} {r[1]} {r[2]} {(math_flags of null).invalid}"' run "SM50 strict pow(-8, 0.5) raises, named" 1 1 "pow: result is not a number" 'print of (pow of [0 - 8, 0.5])' run "SM51 strict elementwise pow raises" 1 1 "pow: result is not a number" 'print of (pow of [[0 - 8, 4], 0.5])' run "SM52 strict num(\"nan\") raises, named" 1 1 "num: result is not a number" 'print of (num of "nan")' @@ -257,6 +272,16 @@ m2[0] is 1e200 m2[1] is 0 - 1e200 local r is matmul of [m1, m2] print of (r[0])' +run "SM55b strict matmul buffer finds NaN after a finite result" 1 1 "matmul: result is not a number" \ +'local a is buffer of [1, 2] +a[0] is 1e200 +a[1] is 1e200 +local b is buffer of [2, 2] +b[0] is 1 +b[1] is 1e200 +b[2] is 1 +b[3] is 0 - 1e200 +print of (matmul of [a, b])' run "SM56 strict divide-by-zero (elementwise) raises" 1 1 "divide: division by zero" 'print of (divide of [[1], [0]])' run "SM57 strict NaN raise is catchable as value" 1 0 "caught value" \ 'try: @@ -315,5 +340,49 @@ run "SM86 strict scan_int_tokens(num) raises" 1 1 "scan_int_tokens: ex run "SM87 default scan_tokens(num) is still []" unset 0 "[]" 'print of f"[{scan_tokens of 42}]"' run "SM88 default scan_int_tokens(num) is still []" unset 0 "[]" 'print of f"[{scan_int_tokens of 42}]"' +# #1131: every f64 matmul rounds the product before adding it. These exact +# binary64 operands give -1 + round((1 + 2^-27) * (1 - 2^-27)) = 0; +# contracting the second multiply-add instead gives -2^-54. Unlike SM49/54/55, +# this witness stays finite, so a NaN/overflow special case cannot satisfy it. +# Pin all three shared kernels, each on both storage roads and in both modes. +for strict_mode in 0 1; do + run "SM89 mode=$strict_mode matmul list rounds product before sum" "$strict_mode" 0 "zero:1:end" \ +'local r is matmul of [[[-1, 1.0000000074505806]], [[1], [0.9999999925494194]]] +print of f"zero:{r[0] == 0}:end"' + run "SM90 mode=$strict_mode matmul buffer rounds product before sum" "$strict_mode" 0 "zero:1:end" \ +'local a is buffer of [1, 2] +a[0] is -1 +a[1] is 1.0000000074505806 +local b is buffer of [2, 1] +b[0] is 1 +b[1] is 0.9999999925494194 +local r is matmul of [a, b] +print of f"zero:{r[0] == 0}:end"' + run "SM91 mode=$strict_mode matmul_at list rounds product before sum" "$strict_mode" 0 "zero:1:end" \ +'local r is matmul_at of [[[-1], [1.0000000074505806]], [[1], [0.9999999925494194]]] +print of f"zero:{r[0][0] == 0}:end"' + run "SM92 mode=$strict_mode matmul_at buffer rounds product before sum" "$strict_mode" 0 "zero:1:end" \ +'local a is buffer of [2, 1] +a[0] is -1 +a[1] is 1.0000000074505806 +local b is buffer of [2, 1] +b[0] is 1 +b[1] is 0.9999999925494194 +local r is matmul_at of [a, b] +print of f"zero:{r[0] == 0}:end"' + run "SM93 mode=$strict_mode matmul_bt list rounds product before sum" "$strict_mode" 0 "zero:1:end" \ +'local r is matmul_bt of [[[-1, 1.0000000074505806]], [[1, 0.9999999925494194]]] +print of f"zero:{r[0] == 0}:end"' + run "SM94 mode=$strict_mode matmul_bt buffer rounds product before sum" "$strict_mode" 0 "zero:1:end" \ +'local a is buffer of [1, 2] +a[0] is -1 +a[1] is 1.0000000074505806 +local b is buffer of [1, 2] +b[0] is 1 +b[1] is 0.9999999925494194 +local r is matmul_bt of [a, b] +print of f"zero:{r[0] == 0}:end"' +done + echo "STRICT: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] diff --git a/tools/lint_message_utf8_check.sh b/tools/lint_message_utf8_check.sh index 6315c888..4c01087e 100755 --- a/tools/lint_message_utf8_check.sh +++ b/tools/lint_message_utf8_check.sh @@ -179,6 +179,11 @@ STUB "$ROOT/src/lint_host.c" | grep -q 'int step = eigs_utf8_step((const unsigned char \*)s + i, n - i);' st "json escaper sanitizer removed" $? [ "$probe" -eq 0 ] || { echo "SELFTEST-FAIL: lint_host.c has no sanitizer to plant against"; st_fail=1; } + # 10. Exercise the real path sweep when file creation rejects invalid + # names, as well as unrelated errors that must not become exemptions. + if ! python3 "$ROOT/tools/lint_source_byte_sweep.py" "$EIGS" --selftest-paths; then + st_fail=1 + fi [ "$st_fail" -eq 0 ] && { echo "OK: gate self-test — planted faults all caught"; exit 0; } echo "FAILED: the gate no longer catches a planted fault"; exit 1 fi @@ -309,7 +314,7 @@ if x is 1: # pre-existing rule is long enough" was wrong as an argument. Both channels are # decoded: the human line and the JSON payload hold separate copies of the # bytes and are repaired at different chokepoints. -sweep_out="$(python3 "$ROOT/tools/lint_message_sweep.py" "$EIGS")" +sweep_out="$(python3 "$ROOT/tools/lint_message_sweep.py" "$EIGS" 2>&1)" if [ $? -ne 0 ]; then bad "identifier-length sweep: $sweep_out"; else note " ok sweep ($sweep_out)"; checked=$((checked + 1)); fi # --- source-byte sweep: bytes the DIAGNOSTIC did not choose ---------------- @@ -318,7 +323,7 @@ if [ $? -ne 0 ]; then bad "identifier-length sweep: $sweep_out"; else note " ok # a source line the caret excerpt echoes. On v0.43.0, 512 of 1524 byte/shape/ # channel combinations were malformed while every message was comfortably # short — which is why a length-only fix left the class open. -byte_out="$(python3 "$ROOT/tools/lint_source_byte_sweep.py" "$EIGS")" +byte_out="$(python3 "$ROOT/tools/lint_source_byte_sweep.py" "$EIGS" 2>&1)" if [ $? -ne 0 ]; then bad "source-byte sweep: $byte_out"; else note " ok bytes ($byte_out)"; checked=$((checked + 1)); fi # --- structural half: the chokepoints are still the only writers ----------- diff --git a/tools/lint_path_fixture.py b/tools/lint_path_fixture.py new file mode 100644 index 00000000..e7bc4ca9 --- /dev/null +++ b/tools/lint_path_fixture.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Create a warning fixture, detecting filesystems that reject invalid names.""" +import errno +import os +import sys + + +def create_warning_file(path): + raw = os.fsencode(path) + try: + raw.decode("utf-8") + invalid_name = False + except UnicodeDecodeError: + invalid_name = True + try: + fixture = open(raw, "wb") + except OSError as error: + # APFS can reject these names at creation. Only a measured encoding + # rejection changes the readable-file population: permissions, space, + # missing directories, and failures on valid names must still fail. + if invalid_name and error.errno in (errno.EILSEQ, errno.EINVAL): + print("NOTE: filesystem rejected invalid UTF-8 filename %r " + "(errno %d); testing its missing-file diagnostics" + % (raw, error.errno), file=sys.stderr) + return False + raise + with fixture: + fixture.write(b"unused_local is 42\nprint of 1\n") + return True + + +if __name__ == "__main__": + print("present" if create_warning_file(sys.argv[1]) else "missing") diff --git a/tools/lint_source_byte_sweep.py b/tools/lint_source_byte_sweep.py index 418df73a..cef73ac2 100644 --- a/tools/lint_source_byte_sweep.py +++ b/tools/lint_source_byte_sweep.py @@ -47,11 +47,14 @@ instead of sweeping clean; a path run must still name the file; and the valid-character cases must be found intact. """ +import json import os import subprocess import sys import tempfile +from lint_path_fixture import create_warning_file + CONTROLS = [0x01, 0x07, 0x1F, 0x7F] SEQ_BAD = { "trunc2": b"\xc3", @@ -93,39 +96,56 @@ def sweep_paths(eigs, bad, vacuous): readable file whose warning payload carries `"file"`, and a missing file (E000, whose whole message is the path).""" runs = 0 - d = tempfile.mkdtemp() - for name, raw in [("invalid", b"bad\xffname"), ("lone-lead", b"bad\xc3name"), - ("valid", "badénom".encode("utf-8"))]: - path = os.path.join(d.encode(), b"w_" + raw + b".eigs") - with open(path, "wb") as f: - f.write(b"unused_local is 42\nprint of 1\n") - for present, target in ((True, path), (False, path + b".missing")): - for mode in ([b"--lint", b"--json"], [b"--lint"]): - r = subprocess.run([eigs.encode()] + mode + [target], - capture_output=True) - out = r.stdout if b"--json" in mode else r.stdout + r.stderr - runs += 1 - label = "%s path %s %s" % (name, "present" if present else "missing", - b" ".join(mode).decode()) - try: - out.decode("utf-8") - except UnicodeDecodeError as e: - bad.append("%s: %s" % (label, e)) - continue - # the payload must still name the file it is talking about - stem = b"w_" - if stem not in out: - vacuous.append("%s: the payload names no file" % label) - if name == "valid" and raw not in out: - vacuous.append("%s: a well-formed path was not echoed intact" - % label) - os.unlink(path) - os.rmdir(d) + with tempfile.TemporaryDirectory() as d: + for name, raw in [("invalid", b"bad\xffname"), ("lone-lead", b"bad\xc3name"), + ("valid", "badénom".encode("utf-8"))]: + path = os.path.join(os.fsencode(d), b"w_" + raw + b".eigs") + # Missing paths exercise invalid argv bytes even when the filesystem + # cannot store them. Readable paths remain covered wherever creation + # succeeds, and a valid UTF-8 readable path is mandatory everywhere. + targets = [(False, path + b".missing")] + if create_warning_file(path): + targets.insert(0, (True, path)) + for present, target in targets: + for mode in ([b"--lint", b"--json"], [b"--lint"]): + r = subprocess.run([eigs.encode()] + mode + [target], + capture_output=True) + out = r.stdout if b"--json" in mode else r.stdout + r.stderr + runs += 1 + label = "%s path %s %s" % (name, "present" if present else "missing", + b" ".join(mode).decode()) + try: + out.decode("utf-8") + except UnicodeDecodeError as e: + bad.append("%s: %s" % (label, e)) + continue + expected_path = target.decode("utf-8", errors="replace") + # --lint defaults to warnings-as-errors, so W001 and + # E000 both return 1; crashes must not look like a match. + if r.returncode != 1: + vacuous.append("%s: unexpected exit %d" % (label, r.returncode)) + if b"--json" in mode: + try: + rows = json.loads(out) + expected_code = "W001" if present else "E000" + if len(rows) != 1 or rows[0].get("code") != expected_code: + raise ValueError("expected one %s diagnostic" % expected_code) + if rows[0].get("file") != expected_path: + raise ValueError("file field did not preserve the sanitized path") + except (ValueError, TypeError, AttributeError) as error: + vacuous.append("%s: %s" % (label, error)) + else: + expected = (expected_path + ":1: warning[W001]:" if present + else "cannot read file '%s'" % expected_path) + if expected.encode("utf-8") not in out: + vacuous.append("%s: missing diagnostic naming the sanitized path" % label) return runs def main() -> int: eigs = sys.argv[1] + if sys.argv[2:] == ["--selftest-paths"]: + return selftest_paths(eigs) bad, vacuous, runs = [], [], 0 byte_cases = [(hex(v), bytes([v])) for v in list(range(0x80, 0x100)) + CONTROLS] for name, b in byte_cases + sorted(SEQ_BAD.items()): @@ -181,5 +201,53 @@ def main() -> int: return 0 +def selftest_paths(eigs): + """Run the real path sweep with filesystem encoding rejection injected.""" + import contextlib + import errno + import io + from unittest.mock import patch + + real_open = open + for code in (errno.EILSEQ, errno.EINVAL): + def reject_invalid(path, *args, **kwargs): + try: + os.fsencode(path).decode("utf-8") + except UnicodeDecodeError: + raise OSError(code, "planted filename encoding rejection", path) + return real_open(path, *args, **kwargs) + + bad, vacuous = [], [] + notes = io.StringIO() + with patch("lint_path_fixture.open", reject_invalid, create=True): + with contextlib.redirect_stderr(notes): + runs = sweep_paths(eigs, bad, vacuous) + # Two missing invalid names and both states of the valid name, each + # on two channels. Omitting the fallback or the valid control fails. + if runs != 8 or bad or vacuous or notes.getvalue().count("NOTE:") != 2: + print("SELFTEST-FAIL: filename rejection errno %d: %d runs; %s" + % (code, runs, bad + vacuous)) + return 1 + print(" selftest ok: filename rejection errno %d retains 8 path runs" % code) + + # The fallback must not turn unrelated fixture failures into coverage + # exemptions, or accept encoding errors on an ordinary valid filename. + for path, code in ((b"invalid\xff.eigs", errno.EACCES), + (b"invalid\xff.eigs", errno.ENOSPC), + (b"valid.eigs", errno.EILSEQ), + (b"valid.eigs", errno.EINVAL)): + with patch("lint_path_fixture.open", side_effect=OSError(code, "planted"), + create=True): + try: + create_warning_file(path) + except OSError as error: + if error.errno == code: + continue + print("SELFTEST-FAIL: unrelated fixture error was waived") + return 1 + print(" selftest ok: 4 unrelated fixture errors remain fatal") + return 0 + + if __name__ == "__main__": sys.exit(main()) From 597b287ac6e165cdec7daa4ce2c796d291706ab2 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 12 Sep 2026 08:03:03 -0500 Subject: [PATCH 8/9] fix(ci): bound sanitizer startup and use fixed LLVM on macOS 26 The Apple Silicon job hung for 28 minutes in the tiny ASan startup control with Homebrew LLVM 20.1.8. Select LLVM 22, whose released runtime contains llvm-project#182943 via the release/22.x backport #188913. Keep the interpreter on Apple Clang and retain the deliberate leak control. Bound sanitizer children with TERM after 120s and KILL after 5s, including the startup and SDL probes. Require a completed positive leak control; a timeout after leak output is a failure. Missing coreutils fails loudly. Give the full native job 45 minutes: Intel completed the gfx gate and 540 compiler invocations but hit 30 minutes in compiler audit selftests. No test populations or required checks are removed. Validation: timeout controls 17 passed, 0 failed under Bash 5 and Bash 3.2; 5 private timeout/exit mutations rejected, 0 missed. Changed compiler audit 4/4 invocations; recognizer coverage 102 scripts, 7 shape waivers. Workflow parse/native coverage, bash syntax and diff checks are green. Native macOS CI must verify the selected runtime and integrated suite. --- .github/workflows/ci.yml | 16 +++- tests/test_asan_gfx.sh | 45 ++++++++++-- tests/test_gfx_timeout.py | 149 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 tests/test_gfx_timeout.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c258db4f..79c81c0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,7 +295,9 @@ jobs: name: macos / ${{ matrix.os }} needs: scope runs-on: ${{ matrix.os }} - timeout-minutes: 30 + # Intel completed the gfx gate and 540 compiler audit calls but reached + # 30 minutes in the audit's fault controls (#1130). Keep the full suite. + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -330,14 +332,22 @@ jobs: # Use upstream LLVM's LSan runtime for the gfx gate, independently # of the default Apple Clang used for the interpreter build. - # Its executable control must start and its intentional leak must fire. + # LLVM 20 deadlocks before main on macOS 26.4+ (llvm-project#182943). + # The fix was backported to LLVM 22 (llvm-project#188913). The gate's + # bounded executable control and intentional leak still verify it. - if: needs.scope.outputs.code == 'true' name: Select gfx LeakSanitizer toolchain run: | case '${{ matrix.os }}' in macos-15-intel) formula=llvm@18 ;; - macos-latest) formula=llvm@20 ;; + macos-latest) + formula=llvm@22 + brew install --force-bottle "$formula" + ;; esac + if ! command -v timeout >/dev/null 2>&1 && ! command -v gtimeout >/dev/null 2>&1; then + brew install --force-bottle coreutils + fi sanitizer_cc="$(brew --prefix "$formula")/bin/clang" test -x "$sanitizer_cc" "$sanitizer_cc" --version diff --git a/tests/test_asan_gfx.sh b/tests/test_asan_gfx.sh index 0adb6511..3c5eb6d4 100755 --- a/tests/test_asan_gfx.sh +++ b/tests/test_asan_gfx.sh @@ -47,11 +47,23 @@ ok() { echo " PASS: $1"; PASS=$((PASS+1)); } bad() { echo " FAIL: $1"; FAIL=$((FAIL+1)); } skip() { echo " SKIP: $1"; echo "ASan gfx: 0 passed, 0 failed (skipped)"; exit 0; } -# A bound in pure shell. Coreutils' `timeout` is absent on the macOS runners, -# and a child that calls it bare dies rc 127 there (test-suite rule). +# The executable startup control can hang inside sanitizer initialization. +# TERM alone cannot bound a runtime which ignores it; KILL follows after 5s. +# BEGIN gfx timeout selector (also exercised by test_gfx_timeout.py) TMO="" -if command -v timeout >/dev/null 2>&1; then TMO="timeout 120" -elif command -v gtimeout >/dev/null 2>&1; then TMO="gtimeout 120"; fi +if command -v timeout >/dev/null 2>&1; then TMO="timeout -k 5 120" +elif command -v gtimeout >/dev/null 2>&1; then TMO="gtimeout -k 5 120" +else + bad "gfx sanitizer gate requires timeout or gtimeout (install coreutils)" + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 +fi +# END gfx timeout selector +if ! python3 "$TESTS_DIR/test_gfx_timeout.py"; then + bad "gfx timeout controls failed" + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 +fi # The -Werror trio is spelled out on every compiler line below rather than folded # into a variable: tools/werror_switch_check.sh reads the line, not the @@ -69,10 +81,12 @@ if ! echo 'int main(void){return 0;}' | "$CC" -Werror=switch -Werror=comment -We fi skip "AddressSanitizer not available in this toolchain" fi -if ! ASAN_OPTIONS=detect_leaks=1 /tmp/eigs_asan_gfx_probe > /tmp/eigs_asan_gfx_probe.log 2>&1; then +ASAN_OPTIONS=detect_leaks=1 $TMO /tmp/eigs_asan_gfx_probe > /tmp/eigs_asan_gfx_probe.log 2>&1 +START_RC=$? +if [ "$START_RC" -ne 0 ]; then cat /tmp/eigs_asan_gfx_probe.log rm -f /tmp/eigs_asan_gfx_probe - bad "gfx sanitizer runtime cannot start with leak detection; set EIGS_ASAN_GFX_CC to a compiler with LeakSanitizer" + bad "gfx sanitizer runtime cannot start with leak detection (rc=$START_RC; 120s deadline, 5s kill grace); set EIGS_ASAN_GFX_CC to a compiler with LeakSanitizer" echo "ASan gfx: $PASS passed, $FAIL failed" exit 1 fi @@ -178,10 +192,12 @@ CEOF CTRL_CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O0" if "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_leak.c -o /tmp/eigs_asan_gfx_leak 2>/dev/null \ && "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_clean.c -o /tmp/eigs_asan_gfx_clean 2>/dev/null; then - if leak_reported /tmp/eigs_asan_gfx_leak && [ "$LAST_RC" -ne 0 ]; then + # The integrated LSan control must finish with its expected failure exit; + # printing a leak and then hanging or dying by signal is not a control pass. + if leak_reported /tmp/eigs_asan_gfx_leak && [ "$LAST_RC" -eq 1 ]; then ok "positive control: a deliberate 1234-byte leak IS reported" else - bad "positive control: a deliberate leak was NOT reported — LeakSanitizer is not armed, so every corpus row below is a blind instrument" + bad "positive control: a deliberate leak did not finish with the expected LSan failure (rc=$LAST_RC) — the corpus verdict would be unvalidated" printf '%s\n' "$LAST_OUT" CTRL_OK=0 fi @@ -256,6 +272,11 @@ for f in "$CORPUS"/*.eigs; do else bad "$base [$mode] leak=$LEAK sanitizer-class=$CLASS rc=$LAST_RC" printf '%s\n' "$LAST_OUT" | tail -8 | sed 's/^/ /' + case "$LAST_RC" in + 124|137) + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 ;; + esac fi done done @@ -266,7 +287,15 @@ echo 'o is gfx_open of [8, 8, "probe"] print of f"sdl-present: {o}" ignore is gfx_close of null' > /tmp/eigs_asan_gfx_sdl.eigs SDL_OUT="$($TMO "$BIN" /tmp/eigs_asan_gfx_sdl.eigs 2>&1)" +SDL_RC=$? rm -f /tmp/eigs_asan_gfx_sdl.eigs +lsan_classify "$SDL_OUT"; SDL_CLASS=$? +if [ "$SDL_RC" -ne 0 ] || [ "$SDL_CLASS" -ne 2 ]; then + bad "SDL availability probe failed (rc=$SDL_RC sanitizer-class=$SDL_CLASS)" + printf '%s\n' "$SDL_OUT" + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 +fi printf '%s' "$SDL_OUT" | grep -q "sdl-present: 1" \ || echo " NOTE: libSDL2 absent — the corpus exercised the argument and"\ "allocation paths but no real renderer or audio device." diff --git a/tests/test_gfx_timeout.py b/tests/test_gfx_timeout.py new file mode 100644 index 00000000..ed93102e --- /dev/null +++ b/tests/test_gfx_timeout.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Exercise the gfx gate's actual timeout and probe paths without gfx or a build.""" +import os +from pathlib import Path +import shlex +import shutil +import signal +import subprocess +import sys +import tempfile + + +def main(): + source = Path(__file__).with_name("test_asan_gfx.sh").read_text() + selector = source.split("# BEGIN gfx timeout selector", 1)[1].split("\n", 1)[1] + selector = selector.split("# END gfx timeout selector", 1)[0] + # Shorten only durations; the command selection and execution stay real. + selector = selector.replace(" -k 5", " -k 0.2").replace(" 120", " 0.5") + runner = source.split("leak_reported() {", 1)[1].split("\n}", 1)[0] + runner = "leak_reported() {" + runner + "\n}\n" + startup = source.split("ASAN_OPTIONS=detect_leaks=1 $TMO /tmp/eigs_asan_gfx_probe", 1) + if len(startup) != 2: + raise AssertionError("startup probe lost the selected timeout") + startup = "ASAN_OPTIONS=detect_leaks=1 $TMO /tmp/eigs_asan_gfx_probe" + startup[1] + startup = startup.split("\nfi\nrm -f /tmp/eigs_asan_gfx_probe", 1)[0] + "\nfi\n" + sdl = source.split('SDL_OUT="$($TMO', 1)[1] + sdl = 'SDL_OUT="$($TMO' + sdl.split("\nprintf '%s' \"$SDL_OUT\"", 1)[0] + positive = source.split(" if leak_reported /tmp/eigs_asan_gfx_leak", 1)[1] + positive = " if leak_reported /tmp/eigs_asan_gfx_leak" + positive.split("\n fi", 1)[0] + "\n fi\n" + corpus = source.split(" # Startup failures and signals", 1)[1] + corpus = " # Startup failures and signals" + corpus.split("\n done", 1)[0] + classifier = Path(__file__).with_name("lsan_classify.sh") + timer = shutil.which("timeout") or shutil.which("gtimeout") + if not timer: + raise AssertionError("timeout controls require timeout or gtimeout") + checks = 0 + with tempfile.TemporaryDirectory(prefix="eigs-gfx-timeout-") as directory: + directory = Path(directory) + # Both command-selection branches run against the installed timer. + for command in ("timeout", "gtimeout"): + os.symlink(timer, directory / command) + child = directory / "child.py" + pidfile = directory / "child.pid" + child_source = ( + "#!" + sys.executable + "\n" + "import os,signal,time\n" + "mode=os.environ['EIGS_TIMEOUT_CONTROL_MODE']\n" + "if mode in ('ignore','leak-ignore'): signal.signal(signal.SIGTERM,signal.SIG_IGN)\n" + "open(" + repr(str(pidfile)) + ", 'w').write(str(os.getpid()))\n" + "if mode=='clean': print('sdl-present: 0'); raise SystemExit(0)\n" + "if mode=='error': raise SystemExit(7)\n" + "if mode.startswith('leak-'): print('==123==ERROR: LeakSanitizer: detected memory leaks',flush=True)\n" + "if mode=='leak-clean': raise SystemExit(1)\n" + "if mode=='leak-error': raise SystemExit(7)\n" + "time.sleep(60)\n" + ) + child.write_text(child_source) + child.chmod(0o755) + declarations = ( + "PASS=0; FAIL=0; LAST_RC=0\n" + "ok() { PASS=$((PASS+1)); }\n" + "bad() { echo \"FAIL: $*\"; FAIL=$((FAIL+1)); }\n" + ". " + shlex.quote(str(classifier)) + "\n" + ) + def run(name, body, mode, expected, *, selection=selector, + hide="", watchdog=False): + nonlocal checks + pidfile.unlink(missing_ok=True) + masking = "" + if hide: + # Override only the selector's availability probe. + masking = "command() { case \"$*\" in " + hide + ") return 1;; esac; builtin command \"$@\"; }\n" + code = declarations + masking + selection + "\n" + body + environment = dict(os.environ, PATH=str(directory) + os.pathsep + os.environ["PATH"], + EIGS_TIMEOUT_CONTROL_MODE=mode) + proc = subprocess.Popen(["bash", "-c", code], env=environment, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + expired = False + try: + try: + out, err = proc.communicate(timeout=2) + except subprocess.TimeoutExpired: + expired = True + if pidfile.exists(): + try: + os.killpg(os.getpgid(int(pidfile.read_text())), signal.SIGKILL) + except ProcessLookupError: + pass + out, err = proc.communicate(timeout=1) + finally: + if proc.poll() is None: + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + if expired != watchdog or (not expired and proc.returncode != expected): + raise AssertionError("%s: watchdog=%s rc=%s stdout=%r stderr=%r" + % (name, expired, proc.returncode, out, err)) + checks += 1 + return out.decode() + + invoke = runner + "leak_reported " + shlex.quote(str(child)) + '\nexit "$LAST_RC"\n' + run("clean child", invoke, "clean", 0) + run("ordinary failure", invoke, "error", 7) + run("TERM deadline", invoke, "hang", 124) + run("TERM-resistant child", invoke, "ignore", 137) + run("gtimeout branch", invoke, "ignore", 137, hide='"-v timeout"') + out = run("missing timeout dependency", invoke, "clean", 1, + hide='"-v timeout"|"-v gtimeout"') + if "requires timeout or gtimeout" not in out or pidfile.exists(): + raise AssertionError("missing dependency executed an unbounded child") + run("removed hard-kill mutation", invoke, "ignore", None, + selection=selector.replace(" -k 0.2", ""), watchdog=True) + positive = positive.replace("/tmp/eigs_asan_gfx_leak", str(child)) + positive = runner + "CTRL_OK=1\n" + positive + '\n[ "$CTRL_OK" = 1 ]\n' + run("completed leak control", positive, "leak-clean", 0) + run("leak then ordinary failure", positive, "leak-error", 1) + run("leak then timeout", positive, "leak-hang", 1) + run("leak then forced kill", positive, "leak-ignore", 1) + for status in (124, 137, 7): + verdict = "base=01.eigs; mode=plain; LEAK=0; LAST_OUT=; LAST_RC=%d\n" % status + verdict += corpus + '\necho CONTINUED\nexit 0\n' + out = run("corpus fail-fast status %d" % status, verdict, "clean", + 0 if status == 7 else 1) + if ("CONTINUED" in out) != (status == 7): + raise AssertionError("corpus timeout continued to another row") + startup = startup.replace("/tmp/eigs_asan_gfx_probe", str(child)) + out = run("actual startup probe", startup, "ignore", 1) + if "rc=137" not in out: + raise AssertionError("startup timeout diagnostic lost the forced-kill status") + # The startup failure cleans its executable. Recreate only this tiny + # generated script for the final probe controls, never a built binary. + child.write_text(child_source) + child.chmod(0o755) + sdl = sdl.replace("/tmp/eigs_asan_gfx_sdl.eigs", str(directory / "sdl.eigs")) + sdl = "BIN=" + shlex.quote(str(child)) + "\n" + sdl + run("SDL clean absence", sdl, "clean", 0) + out = run("SDL timeout is a failure", sdl, "ignore", 1) + if "SDL availability probe failed (rc=137" not in out: + raise AssertionError("SDL timeout was reported as absent library") + print("gfx-timeout controls: %d passed, 0 failed" % checks) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (AssertionError, IndexError, OSError) as error: + print("FAIL: gfx-timeout controls: " + str(error)) + sys.exit(1) From 2c30282bdf8dc1ec5c7ffbc709f5ab9b092ad7a9 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Sat, 12 Sep 2026 10:41:24 -0500 Subject: [PATCH 9/9] fix(ci): retire leak-control roots before sanitizer inspection LLVM22 fixed the macOS26 ARM startup hang, but the original -O0 leak fixture exited 0 without a report. Allocate inside a joined worker which returns NULL so the main thread never retains an allocation pointer. The clean twin differs only by freeing that allocation. Scanner settings stay unchanged, and thread/allocation failures cannot pass either control. Add --toolchain-only to the same gate and run it as an early native CI step. It validates startup and both leak controls before any gfx build, fails when ASan is unavailable, and does not replace the full suite. Validation: real GCC preflight 2/2; timeout controls 17/17; invalid arguments rejected 3/3; independent review PASS. Full graphics population remains 15. Changed compiler audit 4/4; recognizer 102 scripts, 7 shape waivers. Workflow preflight ordering, shell syntax and diff checks are green. Native macOS must verify the real joined-thread controls and full suite. --- .github/workflows/ci.yml | 4 + tests/test_asan_gfx.sh | 180 ++++++++++++++++++++++++--------------- 2 files changed, 114 insertions(+), 70 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79c81c0e..413e299e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -353,6 +353,10 @@ jobs: "$sanitizer_cc" --version echo "EIGS_ASAN_GFX_CC=$sanitizer_cc" >> "$GITHUB_ENV" + - if: needs.scope.outputs.code == 'true' + name: Verify gfx sanitizer controls + run: bash tests/test_asan_gfx.sh --toolchain-only + - if: needs.scope.outputs.code == 'true' name: Run test suite run: cd tests && bash run_all_tests.sh diff --git a/tests/test_asan_gfx.sh b/tests/test_asan_gfx.sh index 3c5eb6d4..dbfd1c30 100755 --- a/tests/test_asan_gfx.sh +++ b/tests/test_asan_gfx.sh @@ -37,6 +37,15 @@ # # Run by hand: cd src && bash ../tests/test_asan_gfx.sh set -u +TOOLCHAIN_ONLY=0 +if [ "$#" -eq 0 ]; then + : +elif [ "$#" -eq 1 ] && [ "$1" = --toolchain-only ]; then + TOOLCHAIN_ONLY=1 +else + echo "Usage: $0 [--toolchain-only]" >&2 + exit 2 +fi TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$TESTS_DIR/.." && pwd)" CORPUS="$TESTS_DIR/gfx_asan_corpus" @@ -74,7 +83,7 @@ ASAN_CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g -O1" if ! echo 'int main(void){return 0;}' | "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $ASAN_CFLAGS -x c - -o /tmp/eigs_asan_gfx_probe 2>/tmp/eigs_asan_gfx_probe.log; then rm -f /tmp/eigs_asan_gfx_probe cat /tmp/eigs_asan_gfx_probe.log - if [ -n "${EIGS_ASAN_GFX_CC:-}" ]; then + if [ -n "${EIGS_ASAN_GFX_CC:-}" ] || [ "$TOOLCHAIN_ONLY" -eq 1 ]; then bad "configured gfx sanitizer compiler cannot build the control: $CC" echo "ASan gfx: $PASS passed, $FAIL failed" exit 1 @@ -96,6 +105,104 @@ export ASAN_OPTIONS="detect_leaks=1:abort_on_error=0" export SDL_VIDEODRIVER="${SDL_VIDEODRIVER:-dummy}" export SDL_AUDIODRIVER="${SDL_AUDIODRIVER:-dummy}" +# ---------------------------------------------------------------- predicate +# ONE predicate, used by the corpus rows AND by the controls. Two copies of +# this decision is how a guard goes green while the production path regresses. +LAST_OUT=""; LAST_RC=0 +leak_reported() { # -> 0 when a leak WAS reported + LAST_OUT="$($TMO "$@" 2>&1)" + LAST_RC=$? + lsan_classify "$LAST_OUT" +} + +# ------------------------------------------------------------- the controls +# The instrument is validated BEFORE any corpus verdict is believed. +CTRL_OK=1 + +cat > /tmp/eigs_asan_gfx_leak.c <<'CEOF' +#include +#include +/* Allocate only inside a worker which is joined before process exit. The + * main thread never holds the pointer, and the retired worker's registers + * and stack cannot conservatively keep it reachable (notably at -O0 on + * AArch64). Do not pass or return the allocation through pthread state. */ +static int allocation_failed; +static void *allocate_in_worker(void *arg) { + (void)arg; + void *allocation = malloc(1234); + if (!allocation) { + allocation_failed = 1; + return NULL; + } + *(volatile unsigned char *)allocation = 42; + return NULL; +} +int main(void) { + pthread_t worker; + if (pthread_create(&worker, NULL, allocate_in_worker, NULL) != 0) return 2; + if (pthread_join(worker, NULL) != 0) return 3; + return allocation_failed ? 4 : 0; +} +CEOF +cat > /tmp/eigs_asan_gfx_clean.c <<'CEOF' +#include +#include +static int allocation_failed; +static void *allocate_in_worker(void *arg) { + (void)arg; + void *allocation = malloc(1234); + if (!allocation) { + allocation_failed = 1; + return NULL; + } + *(volatile unsigned char *)allocation = 42; + free(allocation); + return NULL; +} +int main(void) { + pthread_t worker; + if (pthread_create(&worker, NULL, allocate_in_worker, NULL) != 0) return 2; + if (pthread_join(worker, NULL) != 0) return 3; + return allocation_failed ? 4 : 0; +} +CEOF +CTRL_CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O0" +if "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_leak.c -lpthread -o /tmp/eigs_asan_gfx_leak 2>/dev/null \ +&& "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_clean.c -lpthread -o /tmp/eigs_asan_gfx_clean 2>/dev/null; then + # The integrated LSan control must finish with its expected failure exit; + # printing a leak and then hanging or dying by signal is not a control pass. + if leak_reported /tmp/eigs_asan_gfx_leak && [ "$LAST_RC" -eq 1 ]; then + ok "positive control: a deliberate 1234-byte leak IS reported" + else + bad "positive control: a deliberate leak did not finish with the expected LSan failure (rc=$LAST_RC) — the corpus verdict would be unvalidated" + printf '%s\n' "$LAST_OUT" + CTRL_OK=0 + fi + leak_reported /tmp/eigs_asan_gfx_clean; CLEAN_CLASS=$? + if [ "$CLEAN_CLASS" -eq 2 ] && [ "$LAST_RC" -eq 0 ]; then + ok "negative control: a leak-free program is clean" + else + bad "negative control: a leak-free program did not exit cleanly (rc=$LAST_RC class=$CLEAN_CLASS)" + printf '%s\n' "$LAST_OUT" + CTRL_OK=0 + fi +else + bad "could not compile the leak controls; the corpus verdict would be unvalidated" + CTRL_OK=0 +fi +rm -f /tmp/eigs_asan_gfx_leak.c /tmp/eigs_asan_gfx_clean.c \ + /tmp/eigs_asan_gfx_leak /tmp/eigs_asan_gfx_clean + +if [ "$CTRL_OK" != 1 ]; then + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 +fi + +if [ "$TOOLCHAIN_ONLY" -eq 1 ]; then + echo "ASan gfx toolchain: $PASS passed, $FAIL failed" + exit 0 +fi + # ---------------------------------------------------------------- the binary # Prefer an artifact `make asan-gfx` already produced, but ONLY when it is # newer than every source it was built from. A stale prebuilt is the vacuity @@ -135,22 +242,8 @@ else echo " built /tmp/eigs_asan_gfx from SRC_V_asan-gfx" fi -# ---------------------------------------------------------------- predicate -# ONE predicate, used by the corpus rows AND by the controls. Two copies of -# this decision is how a guard goes green while the production path regresses. -LAST_OUT=""; LAST_RC=0 -leak_reported() { # -> 0 when a leak WAS reported - LAST_OUT="$($TMO "$@" 2>&1)" - LAST_RC=$? - lsan_classify "$LAST_OUT" -} - -# ------------------------------------------------------------- the controls -# The instrument is validated BEFORE any corpus verdict is believed. -CTRL_OK=1 - -# (0) The BINARY under test is an AddressSanitizer build. Decisive about -# $BIN specifically, which the two program controls below are not: they +# The BINARY under test is an AddressSanitizer build. Decisive about +# $BIN specifically, which the two program controls above are not: they # prove the toolchain and the predicate, not which binary was picked. if command -v nm >/dev/null 2>&1 && nm "$BIN" 2>/dev/null | grep -q __asan; then ok "the binary under test links AddressSanitizer (__asan* present)" @@ -163,59 +256,6 @@ else CTRL_OK=0 fi -cat > /tmp/eigs_asan_gfx_leak.c <<'CEOF' -#include -/* Hidden inside a heap cell that is then freed, so the inner block is - * unreachable from every root LeakSanitizer scans. -O0 on purpose: at -O1 - * the pointer survives in a register, LSan is reachability-based, and the - * "leak" is not reported — measured, and it made the control read as - * "LeakSanitizer is not armed" on a toolchain where it plainly was. */ -int main(void) { - void **cell = (void **)malloc(sizeof(void *)); - if (!cell) return 1; - *cell = malloc(1234); - free(cell); - return 0; -} -CEOF -cat > /tmp/eigs_asan_gfx_clean.c <<'CEOF' -#include -int main(void) { - void **cell = (void **)malloc(sizeof(void *)); - if (!cell) return 1; - *cell = malloc(1234); - free(*cell); - free(cell); - return 0; -} -CEOF -CTRL_CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g -O0" -if "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_leak.c -o /tmp/eigs_asan_gfx_leak 2>/dev/null \ -&& "$CC" -Werror=switch -Werror=comment -Werror=misleading-indentation $CTRL_CFLAGS /tmp/eigs_asan_gfx_clean.c -o /tmp/eigs_asan_gfx_clean 2>/dev/null; then - # The integrated LSan control must finish with its expected failure exit; - # printing a leak and then hanging or dying by signal is not a control pass. - if leak_reported /tmp/eigs_asan_gfx_leak && [ "$LAST_RC" -eq 1 ]; then - ok "positive control: a deliberate 1234-byte leak IS reported" - else - bad "positive control: a deliberate leak did not finish with the expected LSan failure (rc=$LAST_RC) — the corpus verdict would be unvalidated" - printf '%s\n' "$LAST_OUT" - CTRL_OK=0 - fi - leak_reported /tmp/eigs_asan_gfx_clean; CLEAN_CLASS=$? - if [ "$CLEAN_CLASS" -eq 2 ] && [ "$LAST_RC" -eq 0 ]; then - ok "negative control: a leak-free program is clean" - else - bad "negative control: a leak-free program did not exit cleanly (rc=$LAST_RC class=$CLEAN_CLASS)" - printf '%s\n' "$LAST_OUT" - CTRL_OK=0 - fi -else - bad "could not compile the leak controls; the corpus verdict would be unvalidated" - CTRL_OK=0 -fi -rm -f /tmp/eigs_asan_gfx_leak.c /tmp/eigs_asan_gfx_clean.c \ - /tmp/eigs_asan_gfx_leak /tmp/eigs_asan_gfx_clean - if [ "$CTRL_OK" != 1 ]; then echo "ASan gfx: $PASS passed, $FAIL failed" exit 1