diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4548bc95..413e299e 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, @@ -516,6 +687,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 +726,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.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/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..05229a83 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 @@ -6398,17 +6413,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_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_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/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..7854829e 100755 --- a/tools/replay_diff.sh +++ b/tools/replay_diff.sh @@ -47,6 +47,13 @@ elif command -v gtimeout >/dev/null 2>&1; then TMO="gtimeout 180"; fi corpus_dir() { printf '%s' "${REPLAY_DIFF_CORPUS:-$ROOT/tests}"; } eig_bin() { printf '%s' "${REPLAY_DIFF_EIG:-./eigenscript}"; } norm() { sed -E 's/0x[0-9a-f]+/0xADDR/g' "$1"; } +ledger_count() { + local count + count=$(wc -l < "$1") || return 1 + count=${count//[[:space:]]/} + case "$count" in ''|*[!0-9]*) return 1;; esac + printf '%s' "$count" +} # stdin is pinned to /dev/null: test_terminal's raw_key reads it, and with the # harness's inherited stdin the record arm hung (rc 124) while the replay arm # exited 3 -- a phantom row from the environment, not the tape. @@ -190,10 +197,11 @@ if [ "$crash" -gt 0 ]; then echo "replay_diff: FAIL: $crash signal exit(s) -- a crash is never a boundary, whatever the arm printed (#1112); $n programs, $boundary at the documented boundary, $nondet nondeterministic" exit 1 fi -if [ "${1:-}" = "--record" ]; then cp "$got" "$BASE"; echo "replay_diff: baseline recorded ($(wc -l < "$BASE") rows, $n programs, $boundary at the documented boundary, $nondet nondeterministic)"; exit 0; fi +if [ "${1:-}" = "--record" ]; then cp "$got" "$BASE"; count=$(ledger_count "$BASE") || { echo "replay_diff: FAIL: invalid ledger count"; exit 1; }; echo "replay_diff: baseline recorded ($count rows, $n programs, $boundary at the documented boundary, $nondet nondeterministic)"; exit 0; fi [ -f "$BASE" ] || { echo "replay_diff: no baseline at $BASE (run with --record)"; cat "$got"; exit 1; } if diff <(sort "$BASE") "$got" > "$T/d"; then - echo "replay_diff: OK ($n programs record+replay; $boundary at the documented boundary; $nondet nondeterministic; $(wc -l < "$BASE") ledgered)"; exit 0 + count=$(ledger_count "$BASE") || { echo "replay_diff: FAIL: invalid ledger count"; exit 1; } + echo "replay_diff: OK ($n programs record+replay; $boundary at the documented boundary; $nondet nondeterministic; $count ledgered)"; exit 0 fi echo "replay_diff: LEDGER CHANGED ($n programs examined)" echo " '<' = ledgered and now identical (improvement -- remove it)" diff --git a/tools/strict_differential.sh b/tools/strict_differential.sh index b24d7ccd..7d480d1c 100755 --- a/tools/strict_differential.sh +++ b/tools/strict_differential.sh @@ -194,15 +194,16 @@ TMP="$(mktemp -d)" # diagnostic re-run printed a completely clean report, because the failing run # was discarded rather than shown. If that happens again this line says so. verdict_printed=0 -_sd_main_pid=$BASHPID +_sd_main_depth=$BASH_SUBSHELL _sd_exit() { local es=$? # ONLY the top-level shell. bash runs an EXIT trap in a subshell that is # killed by a signal too, and this trap both deletes $TMP and speaks: a # signalled command substitution would otherwise remove the temp dir out # from under the still-running parent and print ABORTED before the parent - # reaches its verdict. $BASHPID is per-subshell where $$ is not. - [ "$BASHPID" = "${_sd_main_pid:-}" ] || return 0 + # reaches its verdict. Bash 3 supplies BASH_SUBSHELL; $$ cannot distinguish + # these inherited subshells, and BASHPID requires a newer Bash. + [ "$BASH_SUBSHELL" = "${_sd_main_depth:-}" ] || return 0 rm -rf "${TMP:-}" if [ "${verdict_printed:-0}" != "1" ]; then if [ "$es" = "0" ]; then