diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4548bc95..8251e31b 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: @@ -303,6 +305,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 @@ -311,6 +330,33 @@ 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. + # 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@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 + 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 @@ -331,8 +377,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 @@ -347,6 +423,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 @@ -384,6 +477,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 @@ -392,6 +516,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 @@ -400,6 +541,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 @@ -408,20 +566,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 @@ -472,8 +616,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, @@ -499,6 +670,10 @@ jobs: name: Build with AddressSanitizer + UBSan run: make asan + - if: needs.scope.outputs.code == 'true' + name: Collector traversal ownership and reach + run: python3 tools/gc_traversal_check.py --variant asan + - if: needs.scope.outputs.code == 'true' name: Run full suite under sanitizers env: @@ -516,6 +691,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 @@ -532,8 +730,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: 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 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/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/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.c b/src/eigenscript.c index 78fc91e9..d93a13fd 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -3571,6 +3571,7 @@ typedef struct { int32_t *pinned; /* refs held by the collector's own seed pins * (exit-time snapshot of global bindings) */ uint8_t *mark; + uint8_t *has_node_children; /* valid within this collection, before clearing */ int count, cap; int *table; /* open addressing, holds node index or -1 */ int mask; /* table size - 1 (power of two) */ @@ -3613,6 +3614,7 @@ static int gcu_add(GcU *u, void *obj, int kind) { u->internal = xrealloc_array(u->internal, u->cap, sizeof(int32_t)); u->pinned = xrealloc_array(u->pinned, u->cap, sizeof(int32_t)); u->mark = xrealloc_array(u->mark, u->cap, sizeof(uint8_t)); + u->has_node_children = xrealloc_array(u->has_node_children, u->cap, sizeof(uint8_t)); } int n = u->count++; u->objs[n] = obj; @@ -3620,6 +3622,7 @@ static int gcu_add(GcU *u, void *obj, int kind) { u->internal[n] = 0; u->pinned[n] = 0; u->mark[n] = 0; + u->has_node_children[n] = 0; if (u->count * 4 > (u->mask + 1) * 3) gcu_rehash(u, (u->mask + 1) * 2); uint32_t i = gc_ptr_hash(obj) & u->mask; @@ -3887,8 +3890,11 @@ static void gc_collect_impl(Value **seeds, int seed_count) { for (int i = 0; i < 512; i++) u.table[i] = -1; u.mask = 511; - /* 1. Build U: registered envs + everything reachable via owned edges. - * u.count grows during the scan — the node array is the worklist. */ + /* 1-2. Build U and count internal refs in the same edge walk. u.count + * grows during the scan — the node array is the worklist. Each owned + * edge counts, including duplicate edges and self references. The graph + * stays unchanged until clearing, so discovery also records which nodes + * have no node children; marking need not scan their leaf slots again. */ for (Env *e = g_gc_envs; e; e = e->gc_next) gcu_add(&u, e, GC_KIND_ENV); for (int s = 0; s < seed_count; s++) { @@ -3898,15 +3904,11 @@ static void gc_collect_impl(Value **seeds, int seed_count) { for (int n = 0; n < u.count; n++) { GC_FOR_EACH_CHILD(&u, n, child, child_kind, { gcu_add(&u, child, child_kind); - }); - } - - /* 2. Internal reference counts (edges from inside U). */ - for (int n = 0; n < u.count; n++) { - GC_FOR_EACH_CHILD(&u, n, child, child_kind, { - (void)child_kind; + /* gcu_add may reallocate the arrays; retain indices, not pointers + * into them. The target is now present even on a forward edge. */ int ci = gcu_find(&u, child); - if (ci >= 0) u.internal[ci]++; + u.internal[ci]++; + u.has_node_children[n] = 1; }); } @@ -3932,6 +3934,7 @@ static void gc_collect_impl(Value **seeds, int seed_count) { free(stack); free(u.table); free(u.objs); free(u.kind); free(u.internal); free(u.pinned); free(u.mark); + free(u.has_node_children); g_gc_threshold = gc_next_threshold(g_gc_captured_live, u.count); g_gc_val_threshold = gc_val_next_threshold(u.count); g_in_gc = 0; @@ -3941,6 +3944,7 @@ static void gc_collect_impl(Value **seeds, int seed_count) { /* 4. Mark everything reachable from the roots within U. */ while (sp > 0) { int n = stack[--sp]; + if (!u.has_node_children[n]) continue; GC_FOR_EACH_CHILD(&u, n, child, child_kind, { (void)child_kind; int ci = gcu_find(&u, child); @@ -3978,6 +3982,7 @@ static void gc_collect_impl(Value **seeds, int seed_count) { free(u.table); free(u.objs); free(u.kind); free(u.internal); free(u.pinned); free(u.mark); + free(u.has_node_children); g_gc_threshold = gc_next_threshold(g_gc_captured_live, u.count); g_gc_val_threshold = gc_val_next_threshold(u.count); g_in_gc = 0; 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/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/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/GC_TRAVERSAL.md b/tests/GC_TRAVERSAL.md new file mode 100644 index 00000000..4ac6219c --- /dev/null +++ b/tests/GC_TRAVERSAL.md @@ -0,0 +1,94 @@ +# Collector traversal reuse controls + +The change combines discovery and internal-reference accounting, then skips +marking-time child walks for nodes whose discovery found only leaves. The GC +edge table, thresholds, root and pin accounting, and clearing remain unchanged. +The extra byte array is per collection, not part of Value or its ABI. + +`test_gc_traversal.c` constructs three ownership graphs against the actual +runtime TU, using only temporary source hooks generated by +`tools/gc_traversal_check.py`. No test hooks or counters enter release builds. + +- A live parent owns the same numeric list twice; a separate dead cycle owns + duplicate edges. Internal refcounts must count both edges, live contents and + aliasing survive, two dead nodes are reclaimed, and the numeric list's mark + traversal is actually skipped. +- A 600-node ring forces node-array growth and hash-table rehashing. Every + node's incoming count and child metadata must survive growth. Keeping one + outside owner preserves all nodes; dropping it reclaims all 600. +- A module-cache entry roots exports, a captured environment, a closure, its + chunk, a parked call environment, and a nested chunk. All six survive while + cached and are reclaimed after the cache refs are removed. This exercises + owned env/chunk edges, the current module namespace's owning env backref + (`eigs_module_ns_attach`), and module-cache roots, not the import parser itself. + +Each case runs in a fresh runtime state. Expected named populations are exact: +duplicates: 13 checks / 1 collection / 1 skipped traversal; growth: 2418 checks / +2 collections / 0 skips; namespace: 20 checks / 2 collections / 1 skip. Three +heap-mode checks and the final case-completeness check make 2455 total checks. +The duplicate-count fault expects exactly 2/0/3 failures across those cases +and 1/0/2 skips: its erroneous external roots retain the module cycle, so the +nested leaf chunk is marked again after cache removal. The first release run +demonstrated this second visit and rejected the original one-skip expectation. +The skip-disabled fault expects 1/0/0 failures and zero skips. Both must retain +the same check/collection populations. The driver requires these complete +populations in both release and sanitizer variants. + +After a normal serial build, run the matching focused check: + + make + python3 tools/gc_traversal_check.py --variant release --faults + make asan + python3 tools/gc_traversal_check.py --variant asan --faults + +The runner queries the Makefile's source/object/flag lists, refuses missing or +stale objects, and links a temporary harness containing the actual eigenscript +TU against the same variant's other objects. It never rebuilds/repoints the CLI. +It checks input identities around builds and runs and preserves logs/hashes in +the printed temporary directory (or a new explicit `--out` directory). +Headers are recursively inventoried; GCC/Clang's emitted dependency list must +contain only frozen inputs, including the two temporary sources. A missing +sanitizer classifier or an unknown classifier status is rejected explicitly. +Every C source in the Makefile's resolved variant source list is frozen too, +even when its existing object is reused. Hashes are captured before the object +freshness query and checked afterward, closing a source-edit/freshness race. + +The process owner is shared with `tools/bounded_process.py`: SIGINT/SIGTERM/ +SIGHUP defer cancellation during Popen until the child handle is owned. Failure +cleanup stops and kills all live members of the owned Linux session, including +descendants that changed process group, with a bounded deadline. Handlers are +restored afterward; signal masks are not passed into children. This trusted +process topology must not create new sessions. Linux /proc is required. + +Lightweight ownership/provenance controls inject each signal between OS launch and handle return, +require cleanup of a descendant in another process group, check restored +handlers, and reject changed C/header/object bytes using the runner's verifier: + + python3 tests/test_gc_runner_controls.py --out /tmp/gc-runner-controls + +All six controls passed on 2026-09-11. They launch only tiny temporary Python +child sessions; the three drift controls exercise the shared verifier, not the +full inventory-construction path. + +`--faults` prepares and exercises two source mutations only in temporary copies: +counting duplicate incoming edges only once must fail the ownership witness; +disabling the no-child skip must fail the traversal-reach witness. Hard sanitizer +diagnostics always fail via the suite's shared classifier. Only the intentional +undercount mutation may tolerate its resulting leak report. No nonzero return +without the intended assertion and complete test population qualifies as red. +The harness explicitly flushes its population summaries before returning: +LSan's exit on the deliberate ownership leak can bypass normal stdio flushing. +Failure to write those summaries is a separate hard failure. + +The positive runner is enrolled immediately after `make asan` in the existing CI +sanitizer job, before its full suite; no new compilation Makefile target is +introduced. Also run it explicitly alongside the existing release and ASan +full suites, strict closure-cycle/leak controls, and all required runtime +gates. It does not certify all edge-table shapes, concurrency or allocation +failure. No suite ledger or leak allowance is changed. + +Integration provenance: the production patch starts at current main +`6fa1cb0393fd048cb15ad6e337825812bde35218`. Earlier EMS timing measured a temporary +v0.43.0 overlay; it is not evidence of this main revision's performance. Measure +unchanged main against this candidate independently. The ouroboros VM oracle pin +must remain unchanged until its separate compatibility/pin process is satisfied. diff --git a/tests/binary_swap.sh b/tests/binary_swap.sh new file mode 100644 index 00000000..500f37c5 --- /dev/null +++ b/tests/binary_swap.sh @@ -0,0 +1,33 @@ +# [99d] runs in a subshell so its restoration traps cannot replace suite traps. +# Move the original directory entry aside: copying it would lose hard-link +# identity, and recreating a symlink would lose the original symlink inode. +eigs_binary_swap_selftest() ( + # Bash 3 unwinds function locals before running the EXIT trap. This + # function already has its own subshell, so cleanup state can live there. + scratch="" status=0 + restore_binary() { + trap '' HUP INT TERM + if [ -n "$scratch" ]; then + if [ -e "$scratch/original" ] || [ -L "$scratch/original" ]; then + mv -f "$scratch/original" "$EIGS_BIN" || return 1 + fi + rm -f "$scratch/modified" || return 1 + rmdir "$scratch" || return 1 + fi + } + trap 'status=$?; trap - EXIT; restore_binary || status=125; exit "$status"' EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + record_binary_fingerprint + check_eigs_suite "binary-guard self-test block" "test_gen0_baseline.eigs" "T01" 1 + scratch=$(mktemp -d "${EIGS_BIN}.swap.XXXXXX") || exit 125 + cp -p "$EIGS_BIN" "$scratch/modified" || exit 125 + printf '\n' >> "$scratch/modified" || exit 125 + mv "$EIGS_BIN" "$scratch/original" || exit 125 + mv "$scratch/modified" "$EIGS_BIN" || exit 125 + # Replacement is deliberately synchronous: the guard observes the same + # mid-run change without a sleeping background writer racing restoration. + check_binary_fingerprint + echo "SELFTEST_REACHED_END" +) 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 54ff100f..9b2c5768 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 @@ -5270,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 @@ -6159,19 +6174,21 @@ echo "" # crash in both arms through a wrapper binary (each must FAIL, attributed), # proves --record refuses over a crash, keeps a real clean boundary refusal # classified as boundary (positive control), and pins that a NON-signal -# nonzero rc (124/127) still diffs into a row. The full corpus run stays a +# nonzero rc (120) still diffs into a row. Five further cases require +# exit124 to fail before any self-check/equality/boundary/ledger classification. +# The full corpus run stays a # CI job, not a suite section. The case count is pinned, not ">0": a gate # reduced to one echo satisfies "at least one case passed". -echo "[136] replay_diff crash gate: a signal exit is never a boundary (#1112)" +echo "[136] replay_diff crash/timeout gate: neither is a boundary (#1112)" TOTAL=$((TOTAL + 1)) RDS_OUTPUT=$(bash "$TESTS_DIR/../tools/replay_diff.sh" --selftest 2>&1); RDS_RC=$? RDS_OK=$(printf '%s\n' "$RDS_OUTPUT" | grep -c " selftest ok:" || true) -if [ "$RDS_RC" -eq 0 ] && [ "$RDS_OK" -eq 6 ] && printf '%s\n' "$RDS_OUTPUT" | grep -q "^SELFTEST: all planted faults caught"; then +if [ "$RDS_RC" -eq 0 ] && [ "$RDS_OK" -eq 11 ] && printf '%s\n' "$RDS_OUTPUT" | grep -q "^SELFTEST: all planted faults caught"; then PASS=$((PASS + 1)) echo " PASS: replay_diff selftest (all $RDS_OK planted/control cases)" else FAIL=$((FAIL + 1)) - echo " FAIL: replay_diff selftest (rc=$RDS_RC, $RDS_OK of 6 ok cases)" + echo " FAIL: replay_diff selftest (rc=$RDS_RC, $RDS_OK of 11 ok cases)" printf '%s\n' "$RDS_OUTPUT" | grep -v "selftest ok" | head -8 fi echo "" @@ -6344,47 +6361,18 @@ if [ ! -f "$EIGS_BIN" ]; then PASS=$((PASS + 1)) echo " SKIP: no binary to fingerprint" else - export -f eigs_binary_fingerprint check_binary_fingerprint record_binary_fingerprint check_eigs_suite rc_ok lsan_classify lsan_classify_name derive_count - export EIGS_BIN EIGS_TMO - SELFTEST_BIN_BAK="${EIGS_BIN}.orig" - # If the binary is the #740 variant symlink, remember its target so the - # restore can re-create the link (the swap's mv replaces it with a - # regular file; the link's target file itself is never touched). - SELFTEST_LINK_TARGET=$(readlink "$EIGS_BIN" 2>/dev/null || true) - cp -p "$EIGS_BIN" "$SELFTEST_BIN_BAK" - # Background: wait a moment, then replace the binary with a modified copy. - ( - sleep 1 - cp "$EIGS_BIN" "${EIGS_BIN}.tmp" - printf '\n' >> "${EIGS_BIN}.tmp" - mv "${EIGS_BIN}.tmp" "$EIGS_BIN" - ) & - SWAP_PID=$! - SELFTEST_OUT=$( bash -c ' - record_binary_fingerprint - # Tiny suite subset: one real check block, then a pause for the swap. - check_eigs_suite "binary-guard self-test block" "test_gen0_baseline.eigs" "T01" 1 - sleep 2 - check_binary_fingerprint - echo "SELFTEST_REACHED_END" - ' 2>&1 ) + source "$TESTS_DIR/binary_swap.sh" + SELFTEST_OUT=$(eigs_binary_swap_selftest 2>&1) SELFTEST_RC=$? - wait "$SWAP_PID" 2>/dev/null || true - # Always restore the original binary before the suite continues. - if [ -n "$SELFTEST_LINK_TARGET" ]; then - rm -f "$EIGS_BIN" "$SELFTEST_BIN_BAK" - ln -s "$SELFTEST_LINK_TARGET" "$EIGS_BIN" - else - mv "$SELFTEST_BIN_BAK" "$EIGS_BIN" - fi - export -fn eigs_binary_fingerprint check_binary_fingerprint record_binary_fingerprint check_eigs_suite rc_ok lsan_classify lsan_classify_name derive_count - if [ "$SELFTEST_RC" -ne 0 ] && printf '%s\n' "$SELFTEST_OUT" | grep -qF "ERROR: src/eigenscript changed during the run (rebuilt mid-suite) — results are invalid."; then + RESTORE_OUT=$(python3 "$TESTS_DIR/test_binary_swap_restore.py" 2>&1) + RESTORE_RC=$? + if [ "$SELFTEST_RC" -eq 1 ] && [ "$RESTORE_RC" -eq 0 ] && printf '%s\n' "$SELFTEST_OUT" | grep -qF "ERROR: src/eigenscript changed during the run (rebuilt mid-suite) — results are invalid."; then PASS=$((PASS + 1)) echo " PASS: binary-fingerprint guard detected mid-run swap and aborted with the expected error" else FAIL=$((FAIL + 1)) echo " FAIL: binary-fingerprint guard did not detect mid-run swap (rc=$SELFTEST_RC)" - printf '%s\n' "$SELFTEST_OUT" | head -8 | sed 's/^/ /' + printf '%s\n' "$SELFTEST_OUT" "$RESTORE_OUT" | head -12 | sed 's/^/ /' fi fi echo "" @@ -6398,17 +6386,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_asan_gfx.sh b/tests/test_asan_gfx.sh index ea197fe1..dbfd1c30 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 @@ -36,131 +37,154 @@ # # 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" +. "$TESTS_DIR/lsan_classify.sh" || exit 1 PASS=0; FAIL=0 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 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:-}" ] || [ "$TOOLCHAIN_ONLY" -eq 1 ]; 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 +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 (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 rm -f /tmp/eigs_asan_gfx_probe export ASAN_OPTIONS="detect_leaks=1:abort_on_error=0" export SDL_VIDEODRIVER="${SDL_VIDEODRIVER:-dummy}" export SDL_AUDIODRIVER="${SDL_AUDIODRIVER:-dummy}" -# ---------------------------------------------------------------- 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 -# hazard here: the section would report on code that is no longer in the tree -# and read exactly like a pass. -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" ] \ - && [ -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" - echo " using build/asan-gfx/eigenscript (newer than every src/*.c and src/*.h)" -else - # Build our own, into /tmp. Deliberately NOT `make asan-gfx`: the runner - # re-points src/eigenscript per variant and its #681 fingerprint guard - # 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 \ - -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)" - BIN=/tmp/eigs_asan_gfx - 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_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 # 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 -# 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 - 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)" -else - bad "the binary at $BIN carries no AddressSanitizer symbols — a clean corpus below would mean nothing" - CTRL_OK=0 -fi - cat > /tmp/eigs_asan_gfx_leak.c <<'CEOF' +#include #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. */ +/* 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) { - void **cell = (void **)malloc(sizeof(void *)); - if (!cell) return 1; - *cell = malloc(1234); - free(cell); - return 0; + 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) { - void **cell = (void **)malloc(sizeof(void *)); - if (!cell) return 1; - *cell = malloc(1234); - free(*cell); - free(cell); - return 0; + 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 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 -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 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 - 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" @@ -174,16 +198,86 @@ if [ "$CTRL_OK" != 1 ]; then 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 +# hazard here: the section would report on code that is no longer in the tree +# and read exactly like a pass. +BIN="" +if [ -n "${EIGS_ASAN_GFX:-}" ] && [ -x "${EIGS_ASAN_GFX}" ]; then + BIN="$EIGS_ASAN_GFX" + echo " using EIGS_ASAN_GFX=$BIN" +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" + echo " using build/asan-gfx/eigenscript (newer than every src/*.c and src/*.h)" +else + # Build our own, into /tmp. Deliberately NOT `make asan-gfx`: the runner + # re-points src/eigenscript per variant and its #681 fingerprint guard + # 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) + 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; 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 + +# 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)" +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 +fi + +if [ "$CTRL_OK" != 1 ]; then + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 +fi + # --------------------------------------------------------------- vacuity # The binary must actually CONTAIN the surface under test. A default build # answers "undefined variable" for every gfx name, and a corpus that never # 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 +300,23 @@ 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/^/ /' + case "$LAST_RC" in + 124|137) + echo "ASan gfx: $PASS passed, $FAIL failed" + exit 1 ;; + esac fi done done @@ -225,7 +327,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_binary_swap_restore.py b/tests/test_binary_swap_restore.py new file mode 100644 index 00000000..8bf59b4e --- /dev/null +++ b/tests/test_binary_swap_restore.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Use harmless files and the actual [99d] helper/guard; never run EigenScript.""" +import hashlib +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import tempfile + +HERE=Path(__file__).resolve().parent +ERROR='ERROR: src/eigenscript changed during the run (rebuilt mid-suite) — results are invalid.' +# A direct child sees the restoration subshell as its parent on Bash 3 too. +# Verify that target differs from the enclosing shell before delivering the +# signal; $$ alone would target that enclosing shell and never test restoration. +SIGNAL_CODE='''import os, signal +parent=os.getppid() +outer=int(os.environ["CONTROL_OUTER_PID"]) +if parent<=1 or outer<=1 or parent==outer: + raise RuntimeError("signal helper did not enter restoration subshell") +name=os.environ["CONTROL_SIGNAL"] +os.kill(parent,getattr(signal,"SIG"+name)) +print("binary-swap-signal: "+name+" parent="+str(parent)+" outer="+str(outer),flush=True) +''' +# Original [99d] preservation/restoration operations: deliberately retain this +# broken copy+mv control so an inode-insensitive checker cannot turn green. +OLD=''' +SELFTEST_BIN_BAK="${EIGS_BIN}.orig" +SELFTEST_LINK_TARGET=$(readlink "$EIGS_BIN" 2>/dev/null || true) +cp -p "$EIGS_BIN" "$SELFTEST_BIN_BAK" +cp "$EIGS_BIN" "${EIGS_BIN}.tmp" +printf '\\n' >> "${EIGS_BIN}.tmp" +mv "${EIGS_BIN}.tmp" "$EIGS_BIN" +if [ -n "$SELFTEST_LINK_TARGET" ]; then + rm -f "$EIGS_BIN" "$SELFTEST_BIN_BAK" + ln -s "$SELFTEST_LINK_TARGET" "$EIGS_BIN" +else + mv "$SELFTEST_BIN_BAK" "$EIGS_BIN" +fi +''' +def identity(path): + st=path.lstat() + return (st.st_dev,st.st_ino,st.st_mode,st.st_mtime_ns, + os.readlink(path) if path.is_symlink() else None, + hashlib.sha256(path.read_bytes()).hexdigest()) +def main(): + runner=(HERE/'run_all_tests.sh').read_text() + functions=[] + for name in ['eigs_binary_fingerprint','record_binary_fingerprint','check_binary_fingerprint']: + found=re.findall(r'^'+name+r'\(\) \{\n.*?^\}',runner,re.M|re.S) + assert len(found)==1,name + functions.append(found[0]) + prelude='\n'.join(functions)+''' +ensure_binary_current() { :; } +check_eigs_suite() { printf 'harmless fixture check\\n'; } +source "$RESTORE_HELPER" +''' + positives=negatives=0 + with tempfile.TemporaryDirectory(prefix='binary-swap-control-') as tmp: + for layout in ['hardlink','symlink','standalone']: + for mode in ['guard','HUP','INT','TERM','old']: + root=Path(tmp)/(layout+'-'+mode); root.mkdir() + target=root/'target'; target.write_text('harmless original bytes\n') + alias=root/'alias' + if layout=='hardlink': os.link(target,alias) + elif layout=='symlink': alias.symlink_to('target') + else: alias.write_bytes(target.read_bytes()) + before=identity(alias); target_before=identity(target) + command=OLD if mode=='old' else prelude + if mode in ['HUP','INT','TERM']: + command+='\nexport CONTROL_OUTER_PID=$$\ncheck_binary_fingerprint() { "$CONTROL_PYTHON" -c "$CONTROL_SIGNAL_CODE"; }\n' + if mode!='old': command+='\neigs_binary_swap_selftest\n' + env=dict(os.environ,EIGS_BIN=str(alias),RESTORE_HELPER=str(HERE/'binary_swap.sh'), + CONTROL_SIGNAL=mode,CONTROL_PYTHON=sys.executable,CONTROL_SIGNAL_CODE=SIGNAL_CODE) + result=subprocess.run(['bash','-c',command],env=env,cwd=root,capture_output=True,text=True,timeout=5) + unchanged=identity(alias)==before + detail=(layout,mode,result.returncode,result.stdout,result.stderr) + assert identity(target)==target_before,('target changed',detail) + leftovers=list(root.glob('alias.swap.*')) + assert not leftovers,('scratch not cleaned',[str(p) for p in leftovers],detail) + if mode=='old': + assert result.returncode==0 and not unchanged,(layout,'old defect did not turn red') + negatives+=1 + else: + expected=1 if mode=='guard' else 128+getattr(signal,'SIG'+mode) + assert result.returncode==expected,(layout,mode,result.returncode,result.stdout,result.stderr) + assert unchanged,('original inode/layout/bytes not restored',before,identity(alias),detail) + if mode=='guard': + assert ERROR in result.stdout and 'SELFTEST_REACHED_END' not in result.stdout + else: + sent=re.findall(r'^binary-swap-signal: '+mode+r' parent=(\d+) outer=(\d+)$',result.stdout,re.M) + assert len(sent)==1 and sent[0][0]!=sent[0][1],('signal not delivered to verified subshell',detail) + positives+=1 + assert positives==12 and negatives==3 + print(f'binary-swap-controls: positives={positives} old-defect-rejections={negatives}') +if __name__=='__main__': main() 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_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_gc_runner_controls.py b/tests/test_gc_runner_controls.py new file mode 100644 index 00000000..2037f90c --- /dev/null +++ b/tests/test_gc_runner_controls.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Deferred lightweight runner controls: tiny child sessions and scratch bytes. +No runtime build, VM or solver execution. Run only with the heavy slot released. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time + +ROOT=Path(__file__).resolve().parent.parent +sys.dont_write_bytecode=True +sys.path.insert(0,str(ROOT/'tools')) +import bounded_process as owner +from gc_traversal_check import verify_inputs + +def main(): + ap=argparse.ArgumentParser(description=__doc__) + ap.add_argument('--out',type=Path,required=True) + a=ap.parse_args(); a.out.mkdir(parents=True,exist_ok=False) + rows=[] + real_popen=subprocess.Popen + child_code='''import os,sys,time +p=os.fork() +if p==0: + os.setpgid(0,0) + with open(sys.argv[1],"w") as f: f.write(str(os.getpid())) +time.sleep(30) +''' + for sig in [signal.SIGINT,signal.SIGTERM,signal.SIGHUP]: + label=signal.Signals(sig).name + ready=a.out/(label+'.ready') + created=[] + previous={s:signal.getsignal(s) for s in [signal.SIGINT,signal.SIGTERM,signal.SIGHUP]} + def interrupted_launch(*args,**kwargs): + p=real_popen(*args,**kwargs); created.append(p) + deadline=time.monotonic()+5 + while not ready.exists(): + if time.monotonic()>deadline: raise RuntimeError('tiny child readiness timeout') + time.sleep(.005) + # Signal after OS launch but BEFORE the caller receives its handle. + os.kill(os.getpid(),sig) + return p + owner.subprocess.Popen=interrupted_launch + try: + try: + owner.run_owned([sys.executable,'-c',child_code,str(ready)],a.out/label,10,dict(os.environ),ROOT) + except owner.ProcessCancelled: pass + else: raise AssertionError('launch cancellation accepted') + finally: + owner.subprocess.Popen=real_popen + # The test itself also owns any deliberately intercepted handle, + # so a failing assertion cannot strand its diagnostic children. + for p in created: + remaining=owner.session_members(p.pid) + if remaining: owner.stop_session(p.pid) + p.wait(timeout=2) + record=json.loads((a.out/label/'process.json').read_text()) + assert record['received_signal']==sig + assert record['cleanup'] and not record['cleanup']['remaining'] + assert int(ready.read_text()) in record['cleanup']['signaled'],'different-process-group descendant not cleaned' + assert previous=={s:signal.getsignal(s) for s in previous},'signal handlers not restored' + rows.append({'case':label,'result':'launch cancellation and descendant cleanup verified'}) + # Source-only drift controls call the same exported inventory verifier. + for name in ['other_runtime.c','nested/header.h','object.o']: + p=a.out/name; p.parent.mkdir(parents=True,exist_ok=True); p.write_bytes(b'initial bytes') + frozen={str(p):hashlib.sha256(p.read_bytes()).hexdigest()} + verify_inputs(frozen) + p.write_bytes(b'changed after freshness boundary') + try: verify_inputs(frozen) + except AssertionError as exc: assert 'input changed during collector test:' in str(exc) + else: raise AssertionError('changed frozen input accepted') + rows.append({'case':name,'result':'pristine accepted / drift rejected'}) + (a.out/'result.json').write_text(json.dumps(rows,indent=2)+'\n') + print('gc-runner-controls: 6 controls passed') + +if __name__=='__main__': main() diff --git a/tests/test_gc_traversal.c b/tests/test_gc_traversal.c new file mode 100644 index 00000000..6b40f252 --- /dev/null +++ b/tests/test_gc_traversal.c @@ -0,0 +1,158 @@ +/* The runner instruments a temporary copy of the real collector. No test + * counters, callbacks, or alternate GC implementation enter release builds. */ +#include +#include +static void traversal_discovered(void *universe); +static void traversal_collected(int nodes, int garbage); +static unsigned traversal_skips; +#include "gc_traversal_runtime.c" +#include "eigs_embed.h" + +static int failures, checks, cases, active, want_nodes, want_garbage, collections; +static int want_kinds[3], saw_growth, total_collections; +static Env *namespace_env; +static Value *duplicate_child; +#define CHECK(c,label) do { ++checks; if (!(c)) { \ + fprintf(stderr,"gc-traversal: FAIL %s\n",label); ++failures; } } while (0) + +static void traversal_discovered(void *universe) { + if (!active) return; + GcU *u=universe; + int kinds[3]={0,0,0}; + for (int n=0;ncount;++n) ++kinds[u->kind[n]]; + CHECK(u->count==want_nodes,"discovered population"); + for (int k=0;k<3;++k) CHECK(kinds[k]==want_kinds[k],"node-kind population"); + if (want_nodes>256) { + CHECK(u->cap>=want_nodes && u->mask>511,"array growth and rehash reached"); + saw_growth=1; + for (int n=0;ncount;++n) { + CHECK(u->internal[n]==1,"ring internal ownership survived growth"); + CHECK(u->has_node_children[n]==1,"ring child metadata survived growth"); + } + } + if (namespace_env) { + int n=gcu_find(u,namespace_env); + CHECK(n>=0,"module environment discovered"); + CHECK(n>=0 && u->internal[n]==3,"module incoming edges counted separately"); + } + if (duplicate_child) { + int n=gcu_find(u,duplicate_child); + CHECK(n>=0,"duplicate child discovered"); + if (n>=0) { + CHECK(u->internal[n]==2,"duplicate edges counted separately"); + CHECK(u->has_node_children[n]==0,"numeric list has no node children"); + } + } +} +static void traversal_collected(int nodes,int garbage) { + if (!active) return; + ++collections; + ++total_collections; + CHECK(nodes==want_nodes,"completed population"); + CHECK(garbage==want_garbage,"reclaimed population"); +} +static void collect(int nodes,int garbage,int values,int envs,int chunks) { + want_nodes=nodes; want_garbage=garbage; + want_kinds[0]=values; want_kinds[1]=envs; want_kinds[2]=chunks; + collections=0; active=1; + gc_collect_cycles(); + active=0; + CHECK(collections==1,"one completed collection without accounting abort"); +} + +static void duplicate_and_leaf(void) { + Value *leaf=make_list_heap(64), *root=make_list_heap(2); + for (int i=0;i<64;++i) list_append_owned(leaf,make_num(i)); + list_append(root,leaf); list_append(root,leaf); + duplicate_child=leaf; + val_decref(leaf); gc_note_possible_root(root); + Value *a=make_list_heap(2), *b=make_list_heap(1); + list_append(a,b); list_append(a,b); list_append(b,a); + val_decref(a); val_decref(b); + unsigned before=traversal_skips; + collect(4,2,4,0,0); + CHECK(traversal_skips>before,"numeric-leaf mark traversal skipped"); + CHECK(root->data.list.items[0]==root->data.list.items[1],"live duplicate aliases retained"); + CHECK(root->data.list.items[0]->data.list.items[63]->data.num==63,"live leaf contents retained"); + duplicate_child=NULL; + val_decref(root); gc_collect_cycles(); + ++cases; +} + +static void growing_ring(void) { + enum { N=600 }; + Value **ring=calloc(N,sizeof *ring); + if (!ring) abort(); + for (int i=0;idata.list.items[0]==ring[1],"live ring retained after growth"); + val_decref(ring[0]); + collect(N,N,N,0,0); + free(ring); + CHECK(saw_growth,"growth case reached"); + ++cases; +} + +static void module_env_chunk_cycle(void) { + Env *module=env_new(g_global_env); + Value *fn=make_fn("gc-traversal",NULL,0,module); + env_mark_captured(module); + EigsChunk *chunk=chunk_new("gc-traversal"); + /* Transfer creator refs exactly as the VM's owning chunk fields do. */ + chunk->functions[chunk->fn_count++]=chunk_new("nested"); + chunk->env_cache=env_new(module); + fn->data.fn.body=(ASTNode **)chunk; + fn->data.fn.body_count=-1; + Value *exports=make_dict(1); + dict_set(exports,"fn",fn); + env_set_local(module,"exports",exports); + env_set_local(module,"fn",fn); + eigs_module_ns_attach(exports,module); + namespace_env=module; + eigs_module_cache_put("gc-traversal-test-module",exports,module); + val_decref(fn); val_decref(exports); env_decref(module); + collect(6,0,2,2,2); + Value *cached=NULL; + CHECK(eigs_module_cache_get("gc-traversal-test-module",&cached),"module cache remains a root"); + CHECK(cached==exports,"module exports identity retained"); + /* cache_get returns an owned ref; drop it before removing cache roots. */ + val_decref(cached); + eigs_module_cache_clear(); + collect(6,6,2,2,2); + namespace_env=NULL; + ++cases; +} + +static void run_case(const char *name,void (*body)(void)) { + EigsState *state=eigs_open(); + if (!state) exit(2); + CHECK(!g_arena.active,"heap mode"); + gc_collect_cycles(); + int first_check=checks, first_failure=failures, first_collection=total_collections; + unsigned first_skip=traversal_skips; + body(); + printf("gc-case: name=%s checks=%d collections=%d skips=%u failures=%d\n", + name,checks-first_check,total_collections-first_collection, + traversal_skips-first_skip,failures-first_failure); + eigs_close(state); +} +int main(void) { + run_case("duplicates",duplicate_and_leaf); + run_case("growth",growing_ring); + run_case("namespace",module_env_chunk_cycle); + CHECK(cases==3,"all graph cases completed"); + printf("gc-traversal: cases=%d checks=%d failures=%d\n",cases,checks,failures); + /* The deliberate ownership fault can make LSan exit before stdio's + * normal flush. Preserve the complete assertion population first. */ + if (fflush(stdout)==EOF) return 2; + return failures ? 1 : 0; +} 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) 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_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/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/bounded_process.py b/tools/bounded_process.py new file mode 100644 index 00000000..c9d6685c --- /dev/null +++ b/tools/bounded_process.py @@ -0,0 +1,91 @@ +"""Own one trusted subprocess session, including launch-time cancellation. + +Linux /proc supplies session membership (process groups may change). This is +not containment for a child deliberately creating a new session. No signal +mask is inherited by the child: handlers defer cancellation until Popen hands +the parent its process handle. Adapted from EigenMiniSat's bounded runner. +""" +import json +import os +from pathlib import Path +import signal +import subprocess +import time + +class ProcessCancelled(Exception): + pass + +def session_members(session): + members=[] + for entry in Path('/proc').iterdir(): + if not entry.name.isdecimal(): continue + try: fields=(entry/'stat').read_text().rsplit(')',1)[1].split() + except (FileNotFoundError,ProcessLookupError): continue + if int(fields[3])==session and fields[0] not in ('Z','X'): + members.append(int(entry.name)) + return members + +def stop_session(session): + deadline=time.monotonic()+5 + signaled=set() + while True: + members=session_members(session) + if not members: return {'session':session,'signaled':sorted(signaled),'remaining':[]} + for pid in members: + try: os.kill(pid,signal.SIGSTOP) + except ProcessLookupError: pass + for pid in session_members(session): + try: os.kill(pid,signal.SIGKILL); signaled.add(pid) + except ProcessLookupError: pass + if time.monotonic()>=deadline: + return {'session':session,'signaled':sorted(signaled),'remaining':session_members(session)} + time.sleep(.005) + +def run_owned(command,directory,seconds,environment,cwd): + if not Path('/proc/self/stat').is_file(): + raise RuntimeError('bounded session cleanup requires Linux /proc') + directory.mkdir(parents=True,exist_ok=False) + handled=(signal.SIGINT,signal.SIGTERM,signal.SIGHUP) + previous={s:signal.getsignal(s) for s in handled} + process=None; can_raise=False; received=None; cleanup=None; failure=None + def cancelled(number,_frame): + nonlocal received + if received is None: received=number + if can_raise: raise ProcessCancelled(signal.Signals(number).name) + try: + for number in handled: signal.signal(number,cancelled) + with (directory/'stdout').open('wb') as stdout,(directory/'stderr').open('wb') as stderr: + try: + if received is not None: raise ProcessCancelled('cancelled before launch') + process=subprocess.Popen([str(x) for x in command],cwd=cwd,env=environment, + stdout=stdout,stderr=stderr,start_new_session=True) + can_raise=True + if received is not None: raise ProcessCancelled('cancelled during launch') + process.wait(timeout=seconds) + can_raise=False + if session_members(process.pid): + raise RuntimeError('child exited with live session descendants') + except BaseException as exc: + can_raise=False; failure=exc + if process is not None: + try: cleanup=stop_session(process.pid) + finally: + try: process.wait(timeout=1) + except subprocess.TimeoutExpired: pass + finally: + can_raise=False + record={'command':[str(x) for x in command], 'cwd':str(cwd), + 'returncode':None if process is None else process.returncode, + 'timeout_seconds':seconds,'timed_out':isinstance(failure,subprocess.TimeoutExpired), + 'received_signal':received,'cleanup':cleanup, + 'exception':None if failure is None else type(failure).__name__, + 'directory':str(directory)} + (directory/'process.json').write_text(json.dumps(record,indent=2)+'\n') + finally: + can_raise=False + for number,handler in previous.items(): signal.signal(number,handler) + if cleanup and cleanup['remaining']: + raise RuntimeError('owned session cleanup left live children') from failure + if failure is not None: raise failure + if received is not None: raise ProcessCancelled(signal.Signals(received).name) + return record diff --git a/tools/gc_traversal_check.py b/tools/gc_traversal_check.py new file mode 100644 index 00000000..83f9eca9 --- /dev/null +++ b/tools/gc_traversal_check.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Exercise the actual collector with temporary, test-only reach hooks. +Requires an already built, fresh Makefile object variant. Never rebuilds the CLI. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import tempfile +from bounded_process import run_owned + +ROOT=Path(__file__).resolve().parent.parent +def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest() + +def verify_inputs(frozen): + for name,digest in frozen.items(): + assert sha(Path(name))==digest,f"input changed during collector test: {name}" + +def main(): + ap=argparse.ArgumentParser(description=__doc__) + ap.add_argument('--variant',choices=['release','asan'],required=True) + ap.add_argument('--out',type=Path) + ap.add_argument('--faults',action='store_true',help='also require two deliberate runtime faults to fail') + a=ap.parse_args() + out=a.out or Path(tempfile.mkdtemp(prefix='gc-traversal-')) + if a.out: out.mkdir(parents=True,exist_ok=False) + env=dict(os.environ) + for k in ['EIGS_GC_DEBUG','EIGS_TRACE','EIGS_REPLAY']: env.pop(k,None) + env['ASAN_OPTIONS']='detect_leaks=1:halt_on_error=1' + env['UBSAN_OPTIONS']='halt_on_error=1' + records=[]; manifest={'variant':a.variant,'processes':records,'outputs':{}} + def save(): (out/'result.json').write_text(json.dumps(manifest,indent=2)+'\n') + def run(label,cmd,seconds=30,expected=0): + directory=out/label + try: + record=run_owned(cmd,directory,seconds,env,ROOT) + finally: + if (directory/'process.json').exists(): + records.append(json.loads((directory/'process.json').read_text())); save() + if record['returncode']!=expected: + raise AssertionError(f"{label}: expected rc {expected}, got {record['returncode']}; {directory}") + return directory + def variable(name): + d=run('make-'+name,['make','--no-print-directory','-s','print-'+name]) + return shlex.split((d/'stdout').read_text()) + configuration={str(ROOT/n):sha(ROOT/n) for n in ['Makefile','VERSION']} + cc=variable('CC'); flags=variable('FLAGS_'+a.variant); libs=variable('LIBS_'+a.variant) + all_objects=variable('OBJ_'+a.variant) + sources=variable('SRC_V_'+a.variant) + verify_inputs(configuration) + assert all_objects==[str(Path('build')/a.variant/(Path(s).stem+'.o')) for s in sources] + objects=[ROOT/p for p in all_objects if Path(p).name not in ['main.o','eigenscript.o']] + assert objects and all(p.is_file() for p in objects),'build selected runtime variant first' + flags=[f for f in flags if not f.startswith('-DEIGENSCRIPT_VERSION=')] + flags+=['-DEIGENSCRIPT_VERSION="'+(ROOT/'VERSION').read_text().strip()+'"','-I'+str(ROOT/'src')] + inputs=[ROOT/'src/eigenscript.c',ROOT/'tests/test_gc_traversal.c',ROOT/'tests/lsan_classify.sh',Path(__file__),ROOT/'Makefile',ROOT/'VERSION',ROOT/'tools/bounded_process.py',*[ROOT/p for p in sources],*objects,*sorted((ROOT/'src').rglob('*.h'))] + frozen={str(p):sha(p) for p in inputs} + manifest['inputs']=frozen + def verify(): + verify_inputs(frozen) + verify_inputs(configuration) + verify() + run('object-freshness',['make','--no-print-directory','-q',*[str(p.relative_to(ROOT)) for p in objects]]) + verify() + source=(ROOT/'src/eigenscript.c').read_text() + def once(s,old,new): + assert s.count(old)==1,f'expected one hook/fault site: {old}' + return s.replace(old,new,1) + # Mutations affect only temporary copies. Positive/negative builds use the + # same runtime edge table, test harness, compiler flags and other objects. + variants=['positive']+(['duplicate-count','skip-disabled'] if a.faults else []) + for label in variants: + verify(); directory=out/('source-'+label); directory.mkdir() + generated=source + if label=='duplicate-count': + generated=once(generated,' u.internal[ci]++;',' if (u.internal[ci] == 0) u.internal[ci]++;') + skip=' if (!u.has_node_children[n]) continue;' + guard='0 && !u.has_node_children[n]' if label=='skip-disabled' else '!u.has_node_children[n]' + generated=once(generated,skip,' if ('+guard+') { traversal_skips++; continue; }') + generated=once(generated,' /* 3. Roots: refcount > internal + collector pins.', + ' traversal_discovered(&u);\n /* 3. Roots: refcount > internal + collector pins.') + marker=' if (eigs_env_flag("EIGS_GC_DEBUG"))\n fprintf(stderr, "[gc] universe' + generated=once(generated,marker,' traversal_collected(u.count, garbage);\n'+marker) + (directory/'gc_traversal_runtime.c').write_text(generated) + harness=directory/'test_gc_traversal.c' + harness.write_bytes((ROOT/'tests/test_gc_traversal.c').read_bytes()) + binary=directory/'test' + local_inputs={str(harness.resolve()):sha(harness), + str((directory/'gc_traversal_runtime.c').resolve()):sha(directory/'gc_traversal_runtime.c')} + dependencies=directory/'dependencies.d' + run('build-'+label,cc+flags+['-MMD','-MF',dependencies,harness,*objects,*libs,'-o',binary],240) + binary_digest=sha(binary) + manifest['outputs'][label]={'binary':str(binary),'binary_sha256':binary_digest,'status':'built'} + save() + verify() + # Compiler-enumerated local dependencies must all belong to the frozen + # inventory; recursive src/*.h collection includes nested headers. + dep_text=dependencies.read_text().replace('\\\n',' ') + dep_paths=shlex.split(dep_text.split(':',1)[1]) + dependency_hashes={} + for name in dep_paths: + path=Path(name) + if not path.is_absolute(): path=ROOT/path + path=path.resolve() + expected_hash=local_inputs.get(str(path),frozen.get(str(path))) + assert expected_hash is not None,f'unfrozen compiler dependency: {path}' + assert sha(path)==expected_hash,f'compiler dependency changed: {path}' + dependency_hashes[str(path)]=expected_hash + assert sha(binary)==binary_digest,'test executable changed before execution' + d=run('run-'+label,[binary],60,0 if label=='positive' else 1) + assert sha(binary)==binary_digest,'test executable changed during execution' + verify() + assert all(sha(Path(p))==h for p,h in dependency_hashes.items()),'compiled dependency changed during run' + stdout=(d/'stdout').read_text(); stderr=(d/'stderr').read_text() + combined=d/'combined'; combined.write_text(stdout+stderr) + # Use the suite's classifier even at rc=0. Only the deliberate + # undercount fault may tolerate LEAK; HARD is never an expected red. + classify='source "$1"; c=0; lsan_classify "$(cat "$2")" || c=$?; ' + classify+='case "$c" in 0|2) exit 0;; *) exit 1;; esac' if label=='duplicate-count' else 'test "$c" -eq 2' + run('sanitizer-'+label,['bash','-c',classify,'gc-traversal',ROOT/'tests/lsan_classify.sh',combined]) + rows=re.findall(r'^gc-traversal: cases=3 checks=(\d+) failures=(\d+)$',stdout,re.M) + assert len(rows)==1 and int(rows[0][0])==2455,'changed total check population' + case_rows=re.findall(r'^gc-case: name=(\w+) checks=(\d+) collections=(\d+) skips=(\d+) failures=(\d+)$',stdout,re.M) + expected_failures={'positive':[0,0,0],'duplicate-count':[2,0,3],'skip-disabled':[1,0,0]}[label] + # Undercounting makes the module cycle look externally owned after + # cache removal, so its leaf nested chunk is marked/skipped a second + # time. That extra visit is part of this fault's retention witness. + expected_skips={'positive':[1,0,1],'duplicate-count':[1,0,2],'skip-disabled':[0,0,0]}[label] + expected_cases=[(name,str(checks),str(collections),str(skips),str(failures)) + for name,checks,collections,skips,failures in zip( + ['duplicates','growth','namespace'],[13,2418,20],[1,2,2],expected_skips,expected_failures)] + assert case_rows==expected_cases,f'changed per-case population: {case_rows} != {expected_cases}' + assert int(rows[0][1])==sum(expected_failures),'changed failure population' + if label=='positive': + assert int(rows[0][1])==0 and not stderr,'unexpected positive stderr or failure' + else: + witness='duplicate edges counted separately' if label=='duplicate-count' else 'numeric-leaf mark traversal skipped' + assert 'gc-traversal: FAIL '+witness in stderr,'wrong fault rejection' + # A leak-only diagnostic in the deliberate ownership fault is + # expected; an unrelated crash cannot satisfy its textual witness. + assert sha(binary)==binary_digest,'test executable changed during validation' + manifest['outputs'][label]={'binary':str(binary),'binary_sha256':binary_digest,'status':'checked','generated_sha256':sha(directory/'gc_traversal_runtime.c'),'checks':int(rows[0][0]),'failures':int(rows[0][1]),'cases':case_rows,'compiler_dependencies':dependency_hashes} + verify() + assert all(sha(Path(record['binary']))==record['binary_sha256'] for record in manifest['outputs'].values()),'test executable changed before finalization' + manifest['verdict']='complete'; save() + print(f'gc-traversal: {len(variants)} variants checked; evidence {out}') + +if __name__=='__main__': main() 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..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 @@ -202,6 +207,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' ' ')" @@ -301,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 ---------------- @@ -310,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 ----------- @@ -373,7 +386,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 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()) diff --git a/tools/replay_diff.sh b/tools/replay_diff.sh index 90f5ba58..88b49ee4 100755 --- a/tools/replay_diff.sh +++ b/tools/replay_diff.sh @@ -21,8 +21,9 @@ # the diagnostic and then died by SIGSEGV as "boundary" and the run said OK # over a crash; a crash both arms agree on was likewise invisible to the diff. # The crash check runs before any classification and is the first verdict. -# It is a NUMERIC rc >= 128 test: 120-127 (timeout 124, no-such-command -# 127) are ordinary nonzero exits and still get diffed into rows. +# It is a NUMERIC rc >= 128 test. Exit 124 is separately a hard failure +# before classification: it may be the timeout utility OR the program itself. +# Other non-signal nonzero exits still participate in the fidelity comparison. # # Usage: bash tools/replay_diff.sh [--record | --selftest] # --selftest plant a boundary-plus-crash witness and an identical-crash @@ -41,16 +42,33 @@ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT # suite section runs --selftest there (tests/run_all_tests.sh probes the same # way). Unbounded is a hang, not a wrong verdict. TMO="" -if command -v timeout >/dev/null 2>&1; then TMO="timeout 180" -elif command -v gtimeout >/dev/null 2>&1; then TMO="gtimeout 180"; fi +if command -v timeout >/dev/null 2>&1; then TMO="timeout 300" +elif command -v gtimeout >/dev/null 2>&1; then TMO="gtimeout 300"; 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. -run() { local out="$1"; shift; env -u EIGS_JIT_OSR_THRESHOLD EIGS_JIT_OFF=1 "$@" $TMO "$(eig_bin)" "$(corpus_dir)/$b" > "$out" 2>&1 > "$out"; } +run() { + local out="$1" rc; shift + env -u EIGS_JIT_OSR_THRESHOLD EIGS_JIT_OFF=1 "$@" $TMO "$(eig_bin)" "$(corpus_dir)/$b" > "$out" 2>&1 > "$out" + # Runs include self-check and second adjudication arms. Never compare, + # excuse as NONDET/boundary, or ledger a possibly incomplete exit-124 run. + if [ "$rc" -eq 124 ]; then + echo "replay_diff: FAIL: timeout/exit124: $b $(basename "$out") arm" >&2 + exit 1 + fi +} # crash_check ARM FILE: a signal exit is named AND counted; the count is the # first verdict below. rc is the last line the arm wrote (`rc=N`). crash=0; prog_crash=0 @@ -72,7 +90,14 @@ if [ "${1:-}" = "--selftest" ]; then case "\$1" in *test_boundary_crash.eigs) if [ -n "\${EIGS_REPLAY:-}" ]; then echo "Error line 1: recv: not replayable under EIGS_REPLAY (subprocess/concurrency boundary; see docs/TRACE.md)" >&2; kill -SEGV \$\$; fi ;; *test_both_crash.eigs) echo same; kill -SEGV \$\$ ;; - *test_near_crash.eigs) if [ -n "\${EIGS_REPLAY:-}" ]; then echo replay; else echo record; fi; exit 124 ;; + *test_near_crash.eigs) if [ -n "\${EIGS_REPLAY:-}" ]; then echo replay; else echo record; fi; exit 120 ;; + *test_exit124_both.eigs|*test_math_underflow.eigs) echo identical; exit 124 ;; + *test_exit124_boundary.eigs) if [ -n "\${EIGS_REPLAY:-}" ]; then echo "Error: not replayable under EIGS_REPLAY" >&2; exit 124; fi ;; + *test_exit124_adjudication.eigs) + if [ -n "\${EIGS_TRACE:-}" ]; then + if [ -f "$T/second-record" ]; then echo incomplete; exit 124; fi + : > "$T/second-record" + else echo deliberately-different-replay; exit 0; fi ;; esac exec "$REAL" "\$@" W @@ -85,6 +110,10 @@ W bcrash) printf 'print of 1\n' > "$d/test_boundary_crash.eigs" ;; both) printf 'print of 1\n' > "$d/test_both_crash.eigs" ;; near) printf 'print of 1\n' > "$d/test_near_crash.eigs" ;; + timeoutboth) printf 'print of 1\n' > "$d/test_exit124_both.eigs" ;; + timeoutboundary) printf 'print of 1\n' > "$d/test_exit124_boundary.eigs" ;; + timeoutsecond) rm -f "$T/second-record"; printf 'print of 1\n' > "$d/test_exit124_adjudication.eigs" ;; + timeoutself) printf 'print of 1\n' > "$d/test_math_underflow.eigs" ;; esac; done } # st_case NAME WANT_RC FLOOR ARGS -- WANT_SUBSTR... (WANT_SUBSTR must ALL appear) @@ -132,17 +161,31 @@ W mk_corpus "$T/corpus" clean st_case "clean boundary control stays OK, counted" 0 2 -- \ "replay_diff: OK (2 programs record+replay; 1 at the documented boundary; 0 nondeterministic; 0 ledgered)" - # 5. rc 120-127 is NOT a signal. A divergence there must still become a - # row: the first version of this gate skipped on a glob over the rc - # text (`rc=1[2-9][0-9]`), which also swallowed 124 (timeout) and 127 - # (no such command) -- a loud row turned into no row at all. + # 5. Ordinary non-signal nonzero 120 must still become a row. mk_corpus "$T/corpus" near - st_case "non-signal nonzero rc (124) still diffs into a row" 1 2 -- \ + st_case "non-signal nonzero rc (120) still diffs into a row" 1 2 -- \ "replay_diff: LEDGER CHANGED" "> test_near_crash.eigs" # 6. The vacuity floor is not disabled by the plumbing: with no floor # override the tiny corpus is refused by name. st_case "vacuity floor still fires at the default" 1 "" -- \ "the scan is vacuous" + # 7-11. Exit 124 must fail before equality, boundary, re-adjudication, + # ledger writing or the initial self-check can hide an incomplete arm. + mk_corpus "$T/corpus" timeoutboth + st_case "identical exit124 arms hard-fail" 1 2 -- \ + "replay_diff: FAIL: timeout/exit124: test_exit124_both.eigs rec arm" + st_case "--record refuses exit124" 1 2 "--record" \ + "replay_diff: FAIL: timeout/exit124: test_exit124_both.eigs rec arm" + [ -s "$T/ledger" ] && { echo "SELFTEST FAIL: --record wrote a ledger over exit124" >&2; ST_RC=1; } + mk_corpus "$T/corpus" timeoutboundary + st_case "boundary-plus-exit124 hard-fails" 1 2 -- \ + "replay_diff: FAIL: timeout/exit124: test_exit124_boundary.eigs rep arm" + mk_corpus "$T/corpus" timeoutsecond + st_case "second record exit124 hard-fails" 1 2 -- \ + "replay_diff: FAIL: timeout/exit124: test_exit124_adjudication.eigs rec2 arm" + mk_corpus "$T/corpus" timeoutself + st_case "self-check exit124 hard-fails" 1 2 -- \ + "replay_diff: FAIL: timeout/exit124: test_math_underflow.eigs r0 arm" [ "$ST_RC" -eq 0 ] && echo "SELFTEST: all planted faults caught" exit "$ST_RC" fi @@ -171,9 +214,8 @@ for f in "$CORPUS"/test_*.eigs; do # is not "at the boundary"; both used to read as OK. prog_crash=0; crash_check rec "$T/rec"; crash_check rep "$T/rep" # Skip on the FLAG, not on a glob over the rc text: `rc=1[2-9][0-9]` - # also matches 120-127, which are not signals -- rc 124 (timeout) and - # 127 (no such command) would have been silently skipped instead of - # diffed, turning a loud row into no row at all. + # also matches non-signal statuses such as 120 and 127, which still + # need comparison. Exit 124 has already failed in run() above. [ "$prog_crash" -eq 0 ] || continue diff -q <(norm "$T/rec") <(norm "$T/rep") >/dev/null && continue if grep -q "not replayable under EIGS_REPLAY" "$T/rep"; then boundary=$((boundary + 1)); continue; fi @@ -190,10 +232,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