From aade99b7670eaff424bedafb4bf1115ca7298566 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 12:14:24 +0800 Subject: [PATCH] fix(exec): roll back the model write when mxbuild crashes When mxbuild exits non-zero without an error list after a measured baseline, restore the snapshot instead of keeping the write. Without a baseline and in --patch mode behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + project-bin/exec.sh | 24 ++++- tests/wave2/test-exec-crash-rollback.sh | 118 ++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 tests/wave2/test-exec-crash-rollback.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d6d07c14..2811a2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- fix(exec): **when mxbuild crashes on the written model (non-zero exit, no error list) after a baseline that mxbuild did measure, exec.sh now rolls the write back instead of keeping it.** Before, the default path kept the write, the next exec printed "Baseline NOT measured" and built on a model nothing could load. One BUILD-LOG row says the script was rolled back and carries the mxbuild exit code; without a measured baseline (the crash may be the machine) and in `--patch` mode the old behaviour is unchanged. No new refusal, no guard. Extracted from the crash-rollback hunk of #311. Test: `tests/wave2/test-exec-crash-rollback.sh` (synthetic stub mxbuild; 4 of its checks fail on master) — field incident 2026-10-13 (#309) - fix(journey-runner): **a journey file without a string `id` now warns naming the file and uses its basename as the id, so captures are named `-stepN.png` instead of `undefined-stepN.png`; two journeys ending up with the same id (explicit, or a basename that collides with an explicit id) are refused with exit 2 before the browser opens, naming both files.** The only new refusal is a duplicate id; existing id-less journey files keep running. `journey-examples.md` field table updated ([#383](https://github.com/MendixMau/mxcli-project-toolkit/issues/383)) — joshuamoesa - fix(lint-gate): **a ratchet rise now lists the findings that are NEW, not three arbitrary members of the rule.** The baseline keeps a multiset of findings (rule, module, document, message) next to the counts; on a rise the gate prints the surplus per rule, up to three per rule (the rest in `.claude/loop/lint-last.json`), labelled as new or changed findings next to the net rise. An old counts-only baseline falls back to the first three and says they are not necessarily new. Not done here: pre-exec snapshot attribution, a per-slice growth cap, a slice-close lint check (the rest of [#239](https://github.com/MendixMau/mxcli-project-toolkit/issues/239)). Test: `tests/wave2/test-lint-gate-new-findings.sh` — MendixMau (#239) - fix(conformance-check): read ledger rows whose pointer cell is bare (no backticks) and treat status `planned` as not-built; a ledger whose acceptance text is also bare prose is still not measured (acceptance must hold a backticked SHOW/DESCRIBE), but it now exits 2 with a FAULT that says rows were found and the text is prose, instead of "shape changed or glob matched nothing" (TK-006, partial: that ledger shape is diagnosed, not fixed) — field report TK-006 diff --git a/project-bin/exec.sh b/project-bin/exec.sh index a4c85a76..24a98138 100755 --- a/project-bin/exec.sh +++ b/project-bin/exec.sh @@ -1709,9 +1709,27 @@ EOF echo " ✗ mxbuild failed to run (exit $MXBUILD_EXIT) — gate could not verify the model." [ -n "$MXB_WHY" ] && echo " mxbuild says: $MXB_WHY" echo "$MXBUILD_OUT" | grep -v "^$\|icon\|Microsoft\|Assembly\|__" | head -20 || true - echo " → Snapshot preserved. Open in SP to verify manually before proceeding." - # --patch logs its own row below, after rolling back; one row per run. - [ "$PATCH_MODE" = "1" ] || log_build "❌ gate could not run" "mxbuild exit $MXBUILD_EXIT" + # mxbuild CRASHED ON THE WRITTEN MODEL (field 2026-10-13, Mendix 11.14.0, mxcli v0.25.0): a + # two-hop data source passed `check`, then mxbuild's loader died with no errors file. This + # branch kept the write, the next exec printed "Baseline NOT measured" and wrote on top of a + # model nothing could load. BASE_KNOWN=1 means mxbuild ran on this model a moment ago + # (measured baseline, or a clean verification stamp), so the crash is a property of the + # WRITE, not of the machine: roll it back like a failed gate. Without a measured baseline it + # may be the machine (wrong version, JDK), and the old behaviour stays: kept, flagged. + if [ "$PATCH_MODE" != "1" ] && [ "${BASE_KNOWN:-0}" = "1" ]; then + echo " ✗ mxbuild ran on this model before the write and crashed after it — rolling the write back." + restore_snapshot + if [ "$RESTORED" -eq 1 ]; then + log_build "❌ gate could not run" "mxbuild exit $MXBUILD_EXIT on the written model (ran on the baseline) — script rolled back${MXB_WHY:+; mxbuild: $MXB_WHY}" + else + echo " STOP: the model may not load. Restore it before the next exec: ./bin/restore-mpr.sh " + log_build "❌ gate could not run" "mxbuild exit $MXBUILD_EXIT on the written model — NOT rolled back (restore did not run)" + fi + else + echo " → Snapshot preserved. Open in SP to verify manually before proceeding." + # --patch logs its own row below, after rolling back; one row per run. + [ "$PATCH_MODE" = "1" ] || log_build "❌ gate could not run" "mxbuild exit $MXBUILD_EXIT" + fi else GATE_STATE="pass"; GATE_ERRORS=0 echo " ✓ mxbuild: 0 errors — model is clean." diff --git a/tests/wave2/test-exec-crash-rollback.sh b/tests/wave2/test-exec-crash-rollback.sh new file mode 100644 index 00000000..93c0bc9e --- /dev/null +++ b/tests/wave2/test-exec-crash-rollback.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# test-exec-crash-rollback.sh — exec.sh on the DEFAULT path (no --patch, no --on-copy) when +# mxbuild crashes on the written model. +# +# usage: bash tests/wave2/test-exec-crash-rollback.sh [path-to-exec.sh] +# +# Field (2026-10-13, Mendix 11.14.0): a script passed `mxcli check`, mxbuild's loader then died +# with no errors file. exec.sh kept the write; the next exec printed "Baseline NOT measured" and +# built on a model nothing could load. Now: when mxbuild ran on the model BEFORE the write (the +# baseline is known) and exits non-zero with no error list AFTER it, the write is rolled back. +# +# A normal success: the write is kept, "applied" row [guard] +# B baseline ok, then mxbuild crashes (exit 137, no errors file): model byte-identical to +# before, one "rolled back" row, nothing left half-applied +# C mxbuild crashes on the baseline too (baseline not measured): it may be the machine, so +# the old behaviour stays, the write is kept and flagged [guard] +# D a clean error list after the write (an ordinary gate failure): still the normal +# gate-failed rollback, not the crash row [guard] +# +# Synthetic only: throwaway git repo, stub mxcli, stub mxbuild, stub java. Nothing real runs. +set -uo pipefail + +TOOLKIT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SUBJECT="${1:-$TOOLKIT/project-bin/exec.sh}" +case "$SUBJECT" in /*) ;; *) SUBJECT="$PWD/$SUBJECT" ;; esac +WORK="$(cd "$(mktemp -d "${TMPDIR:-/tmp}/exec-crash.XXXXXX")" && pwd -P)" || exit 2 +trap 'rm -rf "$WORK"' EXIT +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok — $1"; } +bad() { FAIL=$((FAIL+1)); echo " FAIL — $1${2:+: $2}"; } +echo "== subject: $SUBJECT" + +P="$WORK/proj" +mkdir -p "$P/bin" "$P/mprcontents" "$P/mdlsource" "$WORK/jdk/bin" +printf 'not a real model\n' > "$P/Fixture.mpr" +printf 'bson\n' > "$P/mprcontents/a.mxunit" +printf 'CREATE MODULE "Nope";\n' > "$P/mdlsource/test.mdl" +cp "$TOOLKIT/project-bin/_common.sh" "$TOOLKIT/project-bin/restore-mpr.sh" "$P/bin/" +cp "$SUBJECT" "$P/bin/exec.sh" +cat > "$P/bin/snapshot-mpr.sh" <<'SNAP' +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")/.." +D=".mpr-snapshots/$(date +%Y%m%d-%H%M%S)-$$-$RANDOM" +mkdir -p "$D"; cp Fixture.mpr "$D/"; cp -r mprcontents "$D/" +echo "Snapshot saved: $D" +SNAP +# The write: `mxcli exec` appends to the model, as a real script would. +cat > "$P/mxcli" <<'MXCLI' +#!/usr/bin/env bash +D="$(cd "$(dirname "$0")" && pwd)" +case "${1:-}" in + check) exit 0 ;; + exec) echo "written by the script" >> "$D/Fixture.mpr"; echo "x" >> "$D/mprcontents/a.mxunit"; echo "fake mxcli exec"; exit 0 ;; +esac +exit 0 +MXCLI +chmod +x "$P/bin/"*.sh "$P/mxcli" +# mxbuild: MXB_MODE per call number, comma list: ok | crash (exit 137, no errors file) | err (a CE0001 error list) +cat > "$WORK/mxbuild" <<'MXB' +#!/usr/bin/env bash +n=$(( $(cat "$MXB_N" 2>/dev/null || echo 0) + 1 )); echo "$n" > "$MXB_N" +mode="$(printf '%s\n' "$MXB_MODES" | tr ',' '\n' | sed -n "${n}p")"; mode="${mode:-ok}" +err=""; for a in "$@"; do case "$a" in --write-errors=*) err="${a#--write-errors=}" ;; esac; done +case "$mode" in + ok) [ -n "$err" ] && printf '{"problems":[]}' > "$err"; exit 0 ;; + crash) echo "Unhandled exception: loader died" >&2; exit 137 ;; + err) [ -n "$err" ] && printf '{"problems":[{"severity":"Error","code":"CE0001","message":"boom","element":{"document":"M.X"}}]}' > "$err"; exit 1 ;; +esac +MXB +printf '#!/usr/bin/env bash\nexit 0\n' > "$WORK/jdk/bin/java" +chmod +x "$WORK/mxbuild" "$WORK/jdk/bin/java" +( cd "$P" && git init -q . && printf '.mpr-snapshots/\ndocs/\n.claude/\n' > .gitignore && git add -A \ + && git -c user.email=t@t -c user.name=t commit -qm fixture ) >/dev/null 2>&1 + +LOG="$P/docs/BUILD-LOG.md" +sum() { cat "$P/Fixture.mpr" "$P/mprcontents/a.mxunit" | cksum; } +BEFORE="$(sum)" # the committed model; run() resets to it +# run