From bbc67b0369c3705c03728655a62e7445ef8953d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 14:13:13 +0000 Subject: [PATCH] refactor(app-restart, test-scratch): share the mxtest guard and mxcli-run pid check via _common.sh mxtest_after_startup, mxtest_left and mxtest_refusal were duplicated in project-bin/app-restart.sh and project-bin/test-scratch.sh. They now live once in project-bin/_common.sh. The copies differed only in a refusal branch that test-scratch never takes; the tool-name argument still selects it, so each script prints byte-for-byte what it printed before. pid_is_mxcli_run (app-restart.sh) becomes mxtk_pid_is_mxcli_run on mxtk_pid_args: Linux matches a whole `run` argument after mxcli, macOS keeps the joined ps line, Windows keeps the tasklist image check. Both scripts exit 2 with the sync remedy when the installed _common.sh predates the move; one fixture case each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VJgWP5vEoAsNsJYqCDMGNw --- CHANGELOG.md | 1 + project-bin/_common.sh | 118 +++++++++++++++++++++++++++++++ project-bin/app-restart.sh | 107 +++------------------------- project-bin/test-scratch.sh | 94 +++--------------------- tests/wave2/test-app-restart.sh | 14 +++- tests/wave2/test-test-scratch.sh | 14 +++- 6 files changed, 161 insertions(+), 187 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff60ea83..1c2f0acb 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 +- process(app-restart, test-scratch): **the mxcli-test-injection guard and the `mxcli run` pid check are one copy in `_common.sh`, not two drifting ones.** `mxtest_after_startup`, `mxtest_left` and `mxtest_refusal` were duplicated in `project-bin/app-restart.sh` and `project-bin/test-scratch.sh` (the copies differed only in a refusal branch test-scratch never takes; the tool-name argument still selects it, so each script prints exactly what it printed). `pid_is_mxcli_run` becomes `mxtk_pid_is_mxcli_run` on `mxtk_pid_args`: on Linux `run` must be a whole argument (an argument that merely contains " run " no longer counts), macOS keeps the joined `ps` line, Windows keeps the `tasklist` image check. Both scripts stop with exit 2 and the `sync-project.sh --upgrade-bin _common.sh` remedy when the installed `_common.sh` predates the move; one fixture case each. — engineering backlog review 2026-10-14 (item 6) - process(build): **Stage 5 drafts the ready set in parallel and applies serially.** The runbook dispatch row said "sequential within a module", which serialised drafting even for independent scripts; it now points to `iterative-build-loop.md` → Gate cost, the single statement of the rule. The unit is the build-plan row: every `BUILD` row whose "Depends on" rows are all applied, minus overlapping writers (two rows that replace, alter or drop+create the same document never share a ready set, and no row is drafted while it overlaps a queued unapplied one; `brd-to-build-plan.md` has Produces name every document a row writes), runs at once, its scripts in order, one `mdl-agent` per script. Applies stay one at a time through `bin/exec.sh`, and the ready set is recomputed after each. A queued draft made before a sibling apply landed is re-checked with `mxcli diff --format struct` (preflight STOP rule 23); any removal line (checked against `describe` per BUG-146) or STOP rule 21 target means re-draft, never patch, because a stale restatement of a shared document is exactly rule 21's silent seed-call loss and passes `check --references`. Report `ready set N, drafted N, applied k`; the `mdl-agent` template says a drafter never applies and never depends on an unapplied draft. Field evidence: none yet. — MendixMau - fix(platform-link): **CLI commits now show their changed documents in Studio Pro History.** Studio Pro's History → Model changes pane reads a `ModelChanges` array from the commit's `refs/notes/mx_metadata` note. Trap 6c's hand-written template set it to `[]`, so every mxcli/agent commit showed "Model changes 0" (field project: 270 of 270 notes empty). New `project-bin/mx-metadata-note.py` (stdlib only) computes the array from the commit's changed `.mxunit` files, in Studio Pro's entry shape: Status, UnitID, UnitType, UnitName and Module (walked up the `.mpr` Unit tree). It has `changes` / `write` / `backfill` / `sync ` subcommands. `install-project-hooks.sh` now also installs post-commit and post-rewrite hooks that write the note and never block, and `--check` covers them, so `sync-project.sh` rolls them out to wired projects. Notes are published with `mx-metadata-note.py sync ` after the branch push. Push config is never touched. With mxcli v0.26+ in the project root the hooks call `mxcli git note --write` instead (mendixlabs/mxcli#1337); the Python script is the fallback for older pins, and on 160 field commits both wrote the same 3,594 entries. Trap 6c rewritten. Measured against Studio Pro's own notes, 51 commits in 5 apps: 3387/3389 entries exact on all five fields. Field run on a 357-commit Mendix 11.15.0 app: 158 notes backfilled and pushed, and Studio Pro History lists the documents per revision. Review fixes: an earlier revision added `HEAD` + the notes ref to `remote..push`. It was dropped because any push refspec replaces `push.default`. A branch tracking a differently named upstream got a new same-named branch on Team Server where `simple` refuses, a missing local notes ref failed the whole push, and nobody knows whether Studio Pro's own push reads that config. The installer now only fetches the remote's notes (non-forced). It removes that exact `HEAD` + notes pair if present, and leaves and reports any other entry. `sync` handles a remote with no notes ref (it used to exit 1) and retries a rejected push once after re-merging. A note that already lists changes (Studio Pro's) is never overwritten by `write`/`backfill` without `--force`, and `sync` keeps a colleague's non-empty note over ours. The mxcli probe greps `git note --help` for `--write`. Hooks and docs call the script through `resolve_py`. Fixture `tests/wave2/test-mx-metadata-note.sh` (40 cases, over real captured template units; single- and two-tree layouts, amend, backfill and `--force`, push exit codes identical to a no-hooks clone, leftover-refspec cleanup, sync to an empty remote and its retry, notes fetch at install, a diverged notes merge; green under `init.defaultBranch` master and main). — field project (Mendix 11.15 app), MendixMau - learn(fixture-seeding): **explore the data structure first, choose rows by use case, scan actions for resets before any run.** Two seeding runs on public template apps: seeding raised journey passes 33 -> 58 and 20 -> 51 of the same sets (FAIL 93 -> 68, 163 -> 132; INVALID unchanged), and a "Reset all demodata" journey wiped accounts so 104 of 127 journeys reported `The username or password you entered is incorrect`, identical on empty and seeded databases. Skill gains three questions a seed must answer, a use-case checklist, a destructive-action scan, a generic traps table with symptoms, measured expectations and a denominator checklist; routing trigger now fires on seed/test/demo data and empty pages. Independent review fixes: object ids follow the Mendix bigint pattern with a business-key guard (no uuids), associations are seeded where the page's path stores them, four new traps (enum names, inheritance chain, calculated attributes, view/external entities), and the port-offset and container-label traps moved to `harness-architecture.md`. — MendixMau diff --git a/project-bin/_common.sh b/project-bin/_common.sh index 48113307..2cc3480d 100755 --- a/project-bin/_common.sh +++ b/project-bin/_common.sh @@ -915,3 +915,121 @@ EOF_ARGS done return 1 } + +# --------------------------------------------------------------------------- +# mxcli run / test identity — shared by app-restart.sh and test-scratch.sh (one copy each until +# 2026-10-14; they had drifted only in a branch test-scratch never took). A caller whose installed +# _common.sh predates this section stops with the sync remedy (`command -v mxtest_left`). +# --------------------------------------------------------------------------- + +# mxtk_pid_is_mxcli_run — 0 when the process IS an `mxcli run` (#304 review). A SIGKILLed +# mxcli leaves its run --local record behind and the OS hands its pid to an unrelated process; +# `kill -0` cannot tell the difference. Linux with /proc: argv one per line (mxtk_pid_args), so +# `run` must be a whole argument after one naming mxcli. macOS (and Linux without /proc): the +# joined `ps -o args=` line, " run " as a word after mxcli. Windows: the image name is mxcli.exe +# (tasklist — Git Bash's ps cannot show a Windows process's arguments, and PowerShell per call is +# slow). Unreadable → 1. +mxtk_pid_is_mxcli_run() { + local a="" nl=' +' + if [ "$(mxtk_platform)" = windows ]; then + a="$(tasklist //FI "PID eq $1" //FO CSV //NH 2>/dev/null | tr -d '\r' | head -1 | cut -d'"' -f2)" + case "$(printf '%s' "$a" | tr '[:upper:]' '[:lower:]')" in mxcli.exe|mxcli) return 0 ;; esac + return 1 + fi + a="$(mxtk_pid_args "$1")" || return 1 + if [ "$(mxtk_platform)" = linux ] && [ -r "/proc/$1/cmdline" ]; then + case "$nl$a$nl" in *mxcli*"${nl}run${nl}"*) return 0 ;; esac + return 1 + fi + case " $a " in *mxcli*" run "*) return 0 ;; esac + return 1 +} + +# ── After-startup left on an mxcli test injection (#244, BUG-157) ─────────── +# `mxcli test` and `mxcli run --test-endpoint` point the model's after-startup microflow at +# MxTest.RegisterEndpoint (MxTest.TestRunner for a plain test run) and chain the original; only a +# clean exit puts it back. A run stopped by a signal leaves it, and the next injection then chains +# RegisterEndpoint to itself: StackOverflowError in the after-startup, the runtime never starts. +# Read straight from the model bytes — no mxcli, no running app: the setting is the BSON string +# element 0x02 "AfterStartupMicroflow" 0x00 of the project-settings unit (mprcontents/**/*.mxunit; +# inside the .mpr itself on a v1 model). Layout checked against real settings units (mxcli's own +# test models). A unit or .mpr without the bytes "MxTest." anywhere is not scanned at all (an 8 MB +# v1 .mpr costs a grep, not an 8 s od). MXTK_SKIP_MXTEST_GUARD=1 skips the check, out loud. +mxtest_after_startup() ( # $1 = file, or - for stdin. Prints the value ('' = none); 1 = no such element + # A subshell with pipefail OFF: awk exits at the first match and od then dies of SIGPIPE; under + # the caller's pipefail that 141 would hide the answer and switch the guard off on a large .mpr. + set +o pipefail + { if [ "$1" = - ]; then od -An -v -tx1; else od -An -v -tx1 "$1"; fi; } 2>/dev/null | awk ' + BEGIN { for (i = 0; i < 256; i++) hv[sprintf("%02x", i)] = i + for (i = 32; i < 127; i++) { c = sprintf("%c", i); ch[sprintf("%02x", i)] = c; hx[c] = sprintf("%02x", i) } + s = "AfterStartupMicroflow"; T = length(s) + 2; t[1] = "02"; t[T] = "00" + for (i = 1; i <= length(s); i++) t[i + 1] = hx[substr(s, i, 1)] + k = 0; st = 0; found = 0 } + { for (f = 1; f <= NF; f++) { b = $f + if (st == 0) { if (b == t[k + 1]) k++; else k = (b == t[1]) ? 1 : 0 + if (k == T) { st = 1; L = 0; m = 1; n = 0; v = "" } } + else if (st == 1) { L += hv[b] * m; m *= 256 + if (++n == 4) { if (L < 1 || L > 1024) { st = 0; k = 0 } else if (L == 1) { found = 1; exit } else { st = 2; n = 0 } } } + else { if (++n < L) v = v ((b in ch) ? ch[b] : "?"); else { found = 1; exit } } } } + END { if (!found) exit 1; print v }' +) +MXTEST_LEFT=""; MXTEST_UNIT=""; MXTEST_HEAD="" +mxtest_left() { # $1 = model dir, $2 = .mpr. 0 = after-startup is an mxcli test injection (sets MXTEST_*) + local unit val cands + MXTEST_LEFT=""; MXTEST_UNIT=""; MXTEST_HEAD="" + if [ "${MXTK_SKIP_MXTEST_GUARD:-0}" = 1 ]; then + echo " ! MXTK_SKIP_MXTEST_GUARD=1 — NOT checking for a leftover mxcli test after-startup (#244). If the app" >&2 + echo " then dies with StackOverflowError in MxTest.RegisterEndpoint, that is what this check was for." >&2 + return 1 + fi + if [ -d "$1/mprcontents" ]; then + cands="$(LC_ALL=C grep -rla --include='*.mxunit' 'AfterStartupMicroflow' "$1/mprcontents" 2>/dev/null)" + else cands="$2"; fi + # Every unit holding the text, not the first: a doc string elsewhere can mention the setting by + # name. Only the BSON element counts, and only a unit that contains "MxTest." can hold the value. + while IFS= read -r unit; do + [ -n "$unit" ] && [ -f "$unit" ] || continue + LC_ALL=C grep -qa 'MxTest\.' "$unit" 2>/dev/null || continue + val="$(mxtest_after_startup "$unit")" || continue + case "$val" in MxTest.RegisterEndpoint|MxTest.TestRunner) ;; *) continue ;; esac + MXTEST_LEFT="$val"; MXTEST_UNIT="$unit" + case "$unit" in *.mxunit) # what HEAD has, so the repair can name it; "?" = not tracked / no git + MXTEST_HEAD="$(git -C "$(dirname "$unit")" show "HEAD:./$(basename "$unit")" 2>/dev/null | mxtest_after_startup -)" || MXTEST_HEAD="?" ;; + *) MXTEST_HEAD="?" ;; esac + return 0 + done <&2 + echo " run --test-endpoint that did not exit cleanly (stopped by a signal or killed; BUG-157)," >&2 + echo " or held right now by a live run --test-endpoint of this project." >&2 + echo " Starting on it recurses (the next injection chains $MXTEST_LEFT to itself: StackOverflowError)," >&2 + echo " or fails the build with CE1613 if the MxTest module is already gone. Either way the app never starts." >&2 + echo " set in: $rel" >&2 + case "$was" in '?') was="" + echo " HEAD: not readable (no git, or the unit is untracked)" >&2 ;; + '') echo " HEAD: no after-startup microflow" >&2 ;; + *) echo " HEAD: $was" >&2 ;; esac + if [ "$1" = test-scratch ]; then + echo " → If this project's app runs with --test-endpoint right now (skills/cloud-dev-environment.md), the" >&2 + echo " live model is fine as it is — test a copy whose settings unit comes from HEAD instead (#244's" >&2 + echo " workaround): git worktree add HEAD, copy the units under test into it, run mxcli test there" >&2 + echo " on free ports. Or stop the live app with Ctrl+C in its own terminal (a clean stop restores it)." >&2 + else + echo " → If this project's app is running with --test-endpoint right now, this is its live injection:" >&2 + echo " stop it with Ctrl+C in its own terminal (a clean stop restores the setting), then retry." >&2 + fi + echo " → Otherwise restore it, then retry:" >&2 + echo " git checkout -- '$rel' (then git status: the run may have left an MxTest module too)" >&2 + echo " or through ./bin/exec.sh (an ALTER SETTINGS — say so in chat; it is what mxcli's own cleanup runs):" >&2 + echo " mdl 1;" >&2 + echo " alter settings runtime (AfterStartupMicroflow: '$was');" >&2 + echo " or Studio Pro: App Settings → Runtime → After startup." >&2 + echo " A false refusal? MXTK_SKIP_MXTEST_GUARD=1 skips this check (and says so)." >&2 +} diff --git a/project-bin/app-restart.sh b/project-bin/app-restart.sh index 641ca416..9fc69e81 100755 --- a/project-bin/app-restart.sh +++ b/project-bin/app-restart.sh @@ -155,91 +155,12 @@ blocked() { exit 4 } # ── After-startup left on an mxcli test injection (#244, BUG-157) ─────────── -# `mxcli test` and `mxcli run --test-endpoint` point the model's after-startup microflow at -# MxTest.RegisterEndpoint (MxTest.TestRunner for a plain test run) and chain the original; only a -# clean exit puts it back. A run stopped by a signal leaves it, and the next injection then chains -# RegisterEndpoint to itself: StackOverflowError in the after-startup, the runtime never starts. -# Read straight from the model bytes — no mxcli, no running app: the setting is the BSON string -# element 0x02 "AfterStartupMicroflow" 0x00 of the project-settings unit (mprcontents/**/*.mxunit; -# inside the .mpr itself on a v1 model). Layout checked against real settings units (mxcli's own -# test models). A unit or .mpr without the bytes "MxTest." anywhere is not scanned at all (an 8 MB -# v1 .mpr costs a grep, not an 8 s od). MXTK_SKIP_MXTEST_GUARD=1 skips the check, out loud. -mxtest_after_startup() ( # $1 = file, or - for stdin. Prints the value ('' = none); 1 = no such element - # A subshell with pipefail OFF: awk exits at the first match and od then dies of SIGPIPE; under - # the caller's pipefail that 141 would hide the answer and switch the guard off on a large .mpr. - set +o pipefail - { if [ "$1" = - ]; then od -An -v -tx1; else od -An -v -tx1 "$1"; fi; } 2>/dev/null | awk ' - BEGIN { for (i = 0; i < 256; i++) hv[sprintf("%02x", i)] = i - for (i = 32; i < 127; i++) { c = sprintf("%c", i); ch[sprintf("%02x", i)] = c; hx[c] = sprintf("%02x", i) } - s = "AfterStartupMicroflow"; T = length(s) + 2; t[1] = "02"; t[T] = "00" - for (i = 1; i <= length(s); i++) t[i + 1] = hx[substr(s, i, 1)] - k = 0; st = 0; found = 0 } - { for (f = 1; f <= NF; f++) { b = $f - if (st == 0) { if (b == t[k + 1]) k++; else k = (b == t[1]) ? 1 : 0 - if (k == T) { st = 1; L = 0; m = 1; n = 0; v = "" } } - else if (st == 1) { L += hv[b] * m; m *= 256 - if (++n == 4) { if (L < 1 || L > 1024) { st = 0; k = 0 } else if (L == 1) { found = 1; exit } else { st = 2; n = 0 } } } - else { if (++n < L) v = v ((b in ch) ? ch[b] : "?"); else { found = 1; exit } } } } - END { if (!found) exit 1; print v }' -) -MXTEST_LEFT=""; MXTEST_UNIT=""; MXTEST_HEAD="" -mxtest_left() { # $1 = model dir, $2 = .mpr. 0 = after-startup is an mxcli test injection (sets MXTEST_*) - local unit val cands - MXTEST_LEFT=""; MXTEST_UNIT=""; MXTEST_HEAD="" - if [ "${MXTK_SKIP_MXTEST_GUARD:-0}" = 1 ]; then - echo " ! MXTK_SKIP_MXTEST_GUARD=1 — NOT checking for a leftover mxcli test after-startup (#244). If the app" >&2 - echo " then dies with StackOverflowError in MxTest.RegisterEndpoint, that is what this check was for." >&2 - return 1 - fi - if [ -d "$1/mprcontents" ]; then - cands="$(LC_ALL=C grep -rla --include='*.mxunit' 'AfterStartupMicroflow' "$1/mprcontents" 2>/dev/null)" - else cands="$2"; fi - # Every unit holding the text, not the first: a doc string elsewhere can mention the setting by - # name. Only the BSON element counts, and only a unit that contains "MxTest." can hold the value. - while IFS= read -r unit; do - [ -n "$unit" ] && [ -f "$unit" ] || continue - LC_ALL=C grep -qa 'MxTest\.' "$unit" 2>/dev/null || continue - val="$(mxtest_after_startup "$unit")" || continue - case "$val" in MxTest.RegisterEndpoint|MxTest.TestRunner) ;; *) continue ;; esac - MXTEST_LEFT="$val"; MXTEST_UNIT="$unit" - case "$unit" in *.mxunit) # what HEAD has, so the repair can name it; "?" = not tracked / no git - MXTEST_HEAD="$(git -C "$(dirname "$unit")" show "HEAD:./$(basename "$unit")" 2>/dev/null | mxtest_after_startup -)" || MXTEST_HEAD="?" ;; - *) MXTEST_HEAD="?" ;; esac - return 0 - done <&2 - echo " run --test-endpoint that did not exit cleanly (stopped by a signal or killed; BUG-157)," >&2 - echo " or held right now by a live run --test-endpoint of this project." >&2 - echo " Starting on it recurses (the next injection chains $MXTEST_LEFT to itself: StackOverflowError)," >&2 - echo " or fails the build with CE1613 if the MxTest module is already gone. Either way the app never starts." >&2 - echo " set in: $rel" >&2 - case "$was" in '?') was="" - echo " HEAD: not readable (no git, or the unit is untracked)" >&2 ;; - '') echo " HEAD: no after-startup microflow" >&2 ;; - *) echo " HEAD: $was" >&2 ;; esac - if [ "$1" = test-scratch ]; then - echo " → If this project's app runs with --test-endpoint right now (skills/cloud-dev-environment.md), the" >&2 - echo " live model is fine as it is — test a copy whose settings unit comes from HEAD instead (#244's" >&2 - echo " workaround): git worktree add HEAD, copy the units under test into it, run mxcli test there" >&2 - echo " on free ports. Or stop the live app with Ctrl+C in its own terminal (a clean stop restores it)." >&2 - else - echo " → If this project's app is running with --test-endpoint right now, this is its live injection:" >&2 - echo " stop it with Ctrl+C in its own terminal (a clean stop restores the setting), then retry." >&2 - fi - echo " → Otherwise restore it, then retry:" >&2 - echo " git checkout -- '$rel' (then git status: the run may have left an MxTest module too)" >&2 - echo " or through ./bin/exec.sh (an ALTER SETTINGS — say so in chat; it is what mxcli's own cleanup runs):" >&2 - echo " mdl 1;" >&2 - echo " alter settings runtime (AfterStartupMicroflow: '$was');" >&2 - echo " or Studio Pro: App Settings → Runtime → After startup." >&2 - echo " A false refusal? MXTK_SKIP_MXTEST_GUARD=1 skips this check (and says so)." >&2 -} +# The reader (mxtest_after_startup / mxtest_left / mxtest_refusal) and the pid check +# (mxtk_pid_is_mxcli_run) live in _common.sh, shared with test-scratch.sh. Same stop as above for a +# _common.sh that predates them. +command -v mxtest_left >/dev/null 2>&1 && command -v mxtk_pid_is_mxcli_run >/dev/null 2>&1 || { + echo "app-restart: bin/_common.sh is older than this script (no mxtest_left / mxtk_pid_is_mxcli_run)." >&2 + echo " → /bin/sync-project.sh $PROJECT_ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } # in_our_tree — the recorded pid itself, or a descendant of it (the runtime mxcli started). in_our_tree() { [ "$ALIVE" = 1 ] && mxtk_pid_in_tree "$1" "$PID"; } @@ -266,24 +187,12 @@ fi # and the start-command check below cannot either when --cmd / MXTK_APP_START_CMD supplies the # command — so without this a stale record plus a reused pid sent TERM/KILL to that process and # its children. Checked on the pid itself, whichever way the start command is learned: its -# command line (/proc//cmdline, else `ps -o args=`) names mxcli and `run`; on Windows the +# command line names mxcli and `run` (mxtk_pid_is_mxcli_run in _common.sh); on Windows the # image name is mxcli.exe. Anything else is treated as not running: never stopped, and if it # holds one of the ports the port check below names it and stops. -pid_is_mxcli_run() { - local a="" - if [ "$(mxtk_platform)" = windows ]; then - a="$(tasklist //FI "PID eq $1" //FO CSV //NH 2>/dev/null | tr -d '\r' | head -1 | cut -d'"' -f2)" - case "$(printf '%s' "$a" | tr '[:upper:]' '[:lower:]')" in mxcli.exe|mxcli) return 0 ;; esac - return 1 - fi - if [ -r "/proc/$1/cmdline" ]; then a="$(tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null)" - else a="$(ps -o args= -p "$1" 2>/dev/null)"; fi - case " $a " in *mxcli*" run "*) return 0 ;; esac - return 1 -} ALIVE=0 if pid_alive "$PID"; then - if pid_is_mxcli_run "$PID"; then ALIVE=1 + if mxtk_pid_is_mxcli_run "$PID"; then ALIVE=1 else echo " ! recorded pid $PID is alive but is not an mxcli run (the pid was reused after the app died) — treating the app as not running; that process is not touched"; fi fi ADMIN_PORT="$(hs_num adminPort "$HS")"; [ -n "$ADMIN_PORT" ] || ADMIN_PORT=$((PORT + 10)) diff --git a/project-bin/test-scratch.sh b/project-bin/test-scratch.sh index d20192cf..09f019fe 100755 --- a/project-bin/test-scratch.sh +++ b/project-bin/test-scratch.sh @@ -4,7 +4,7 @@ # ./bin/test-scratch.sh [--keep] [--ports "8081 8091"] -- # e.g. ./bin/test-scratch.sh -- tests/unit/Orders.test.mdl --local # -# Exit: mxcli's own exit code · 2 usage · 3 refused (nothing copied or run) +# Exit: mxcli's own exit code · 2 usage, or bin/_common.sh older than this script · 3 refused (nothing copied or run) # # WHY (#278, field run 2026-10-12; testing-shape.md § "mxcli test --local does NOT leave a warm # run --local app alone"). `mxcli test` injects a TestRunner, sets security off, builds into @@ -61,91 +61,13 @@ elif command -v mxcli >/dev/null 2>&1; then MXCLI="$(command -v mxcli)" else echo "✗ test-scratch: no mxcli (project root or PATH)." >&2; exit 3; fi # ── After-startup left on an mxcli test injection (#244, BUG-157) ─────────── -# `mxcli test` and `mxcli run --test-endpoint` point the model's after-startup microflow at -# MxTest.RegisterEndpoint (MxTest.TestRunner for a plain test run) and chain the original; only a -# clean exit puts it back. A run stopped by a signal leaves it, and the next injection then chains -# RegisterEndpoint to itself: StackOverflowError in the after-startup, the runtime never starts. -# Read straight from the model bytes — no mxcli, no running app: the setting is the BSON string -# element 0x02 "AfterStartupMicroflow" 0x00 of the project-settings unit (mprcontents/**/*.mxunit; -# inside the .mpr itself on a v1 model). Layout checked against real settings units (mxcli's own -# test models). A unit or .mpr without the bytes "MxTest." anywhere is not scanned at all (an 8 MB -# v1 .mpr costs a grep, not an 8 s od). MXTK_SKIP_MXTEST_GUARD=1 skips the check, out loud. -mxtest_after_startup() ( # $1 = file, or - for stdin. Prints the value ('' = none); 1 = no such element - # A subshell with pipefail OFF: awk exits at the first match and od then dies of SIGPIPE; under - # the caller's pipefail that 141 would hide the answer and switch the guard off on a large .mpr. - set +o pipefail - { if [ "$1" = - ]; then od -An -v -tx1; else od -An -v -tx1 "$1"; fi; } 2>/dev/null | awk ' - BEGIN { for (i = 0; i < 256; i++) hv[sprintf("%02x", i)] = i - for (i = 32; i < 127; i++) { c = sprintf("%c", i); ch[sprintf("%02x", i)] = c; hx[c] = sprintf("%02x", i) } - s = "AfterStartupMicroflow"; T = length(s) + 2; t[1] = "02"; t[T] = "00" - for (i = 1; i <= length(s); i++) t[i + 1] = hx[substr(s, i, 1)] - k = 0; st = 0; found = 0 } - { for (f = 1; f <= NF; f++) { b = $f - if (st == 0) { if (b == t[k + 1]) k++; else k = (b == t[1]) ? 1 : 0 - if (k == T) { st = 1; L = 0; m = 1; n = 0; v = "" } } - else if (st == 1) { L += hv[b] * m; m *= 256 - if (++n == 4) { if (L < 1 || L > 1024) { st = 0; k = 0 } else if (L == 1) { found = 1; exit } else { st = 2; n = 0 } } } - else { if (++n < L) v = v ((b in ch) ? ch[b] : "?"); else { found = 1; exit } } } } - END { if (!found) exit 1; print v }' -) -MXTEST_LEFT=""; MXTEST_UNIT=""; MXTEST_HEAD="" -mxtest_left() { # $1 = model dir, $2 = .mpr. 0 = after-startup is an mxcli test injection (sets MXTEST_*) - local unit val cands - MXTEST_LEFT=""; MXTEST_UNIT=""; MXTEST_HEAD="" - if [ "${MXTK_SKIP_MXTEST_GUARD:-0}" = 1 ]; then - echo " ! MXTK_SKIP_MXTEST_GUARD=1 — NOT checking for a leftover mxcli test after-startup (#244). If the app" >&2 - echo " then dies with StackOverflowError in MxTest.RegisterEndpoint, that is what this check was for." >&2 - return 1 - fi - if [ -d "$1/mprcontents" ]; then - cands="$(LC_ALL=C grep -rla --include='*.mxunit' 'AfterStartupMicroflow' "$1/mprcontents" 2>/dev/null)" - else cands="$2"; fi - # Every unit holding the text, not the first: a doc string elsewhere can mention the setting by - # name. Only the BSON element counts, and only a unit that contains "MxTest." can hold the value. - while IFS= read -r unit; do - [ -n "$unit" ] && [ -f "$unit" ] || continue - LC_ALL=C grep -qa 'MxTest\.' "$unit" 2>/dev/null || continue - val="$(mxtest_after_startup "$unit")" || continue - case "$val" in MxTest.RegisterEndpoint|MxTest.TestRunner) ;; *) continue ;; esac - MXTEST_LEFT="$val"; MXTEST_UNIT="$unit" - case "$unit" in *.mxunit) # what HEAD has, so the repair can name it; "?" = not tracked / no git - MXTEST_HEAD="$(git -C "$(dirname "$unit")" show "HEAD:./$(basename "$unit")" 2>/dev/null | mxtest_after_startup -)" || MXTEST_HEAD="?" ;; - *) MXTEST_HEAD="?" ;; esac - return 0 - done <&2 - echo " run --test-endpoint that did not exit cleanly (stopped by a signal or killed; BUG-157)," >&2 - echo " or held right now by a live run --test-endpoint of this project." >&2 - echo " Starting on it recurses (the next injection chains $MXTEST_LEFT to itself: StackOverflowError)," >&2 - echo " or fails the build with CE1613 if the MxTest module is already gone. Either way the app never starts." >&2 - echo " set in: $rel" >&2 - case "$was" in '?') was="" - echo " HEAD: not readable (no git, or the unit is untracked)" >&2 ;; - '') echo " HEAD: no after-startup microflow" >&2 ;; - *) echo " HEAD: $was" >&2 ;; esac - if [ "$1" = test-scratch ]; then - echo " → If this project's app runs with --test-endpoint right now (skills/cloud-dev-environment.md), the" >&2 - echo " live model is fine as it is — test a copy whose settings unit comes from HEAD instead (#244's" >&2 - echo " workaround): git worktree add HEAD, copy the units under test into it, run mxcli test there" >&2 - echo " on free ports. Or stop the live app with Ctrl+C in its own terminal (a clean stop restores it)." >&2 - else - echo " → If this project's app is running with --test-endpoint right now, this is its live injection:" >&2 - echo " stop it with Ctrl+C in its own terminal (a clean stop restores the setting), then retry." >&2 - fi - echo " → Otherwise restore it, then retry:" >&2 - echo " git checkout -- '$rel' (then git status: the run may have left an MxTest module too)" >&2 - echo " or through ./bin/exec.sh (an ALTER SETTINGS — say so in chat; it is what mxcli's own cleanup runs):" >&2 - echo " mdl 1;" >&2 - echo " alter settings runtime (AfterStartupMicroflow: '$was');" >&2 - echo " or Studio Pro: App Settings → Runtime → After startup." >&2 - echo " A false refusal? MXTK_SKIP_MXTEST_GUARD=1 skips this check (and says so)." >&2 -} +# The reader and the refusal (mxtest_after_startup / mxtest_left / mxtest_refusal) live in +# _common.sh, shared with app-restart.sh. A project whose bin/_common.sh predates them stops here +# with the remedy, instead of dying on "command not found" — or, worse, reading the failed call as +# "no injection left" and copying a model that recurses in its after-startup. +command -v mxtest_left >/dev/null 2>&1 && command -v mxtest_refusal >/dev/null 2>&1 || { + echo "test-scratch: bin/_common.sh is older than this script (no mxtest_left / mxtest_refusal)." >&2 + echo " → /bin/sync-project.sh $PROJECT_ROOT --upgrade-bin _common.sh (backs up a hand-edited copy first)" >&2; exit 2; } # A model whose after-startup is an mxcli test injection must not be copied and tested: the copy # carries it, and mxcli test then chains the injection to itself (#244 observation 1). diff --git a/tests/wave2/test-app-restart.sh b/tests/wave2/test-app-restart.sh index 21d6f2f5..d556ba43 100755 --- a/tests/wave2/test-app-restart.sh +++ b/tests/wave2/test-app-restart.sh @@ -23,7 +23,8 @@ # stranger named by pid and alive; our app dead and a stranger on the app port → exit 4, nothing # started. (The stranger is a bare TCP listener, as a runtime's admin port is to curl.) # #160: the port/tree helpers moved to _common.sh; an older _common.sh stops with exit 2 + the -# sync remedy (this is the case where it would otherwise die on "command not found"). +# sync remedy (this is the case where it would otherwise die on "command not found"); so does one older +# than the mxtest/pid helpers that moved there from this script and test-scratch.sh. # #304 review: a stale record whose pid was reused by an unrelated process, restarted with --cmd → # that process survives (the pid is checked to be an mxcli run itself). #244: the settings unit is # the CAPTURED one (fixtures/mxtest-guard, from mxcli's own test model), re-encoded with the @@ -277,6 +278,17 @@ OUT="$(restart "$O" --dry-run)"; RC=$? has "names the remedy" "$OUT" "sync-project.sh" hasnt "no command-not-found mid-run" "$OUT" "command not found" +echo "-- a bin/_common.sh older than the shared mxtest/pid helpers → exit 2 naming sync, nothing stopped" +O2="$WORK/oldcommon2"; mkapp "$O2" "$PORT" +sed '/^# mxcli run \/ test identity/,$d' "$TOOLKIT/project-bin/_common.sh" > "$O2/bin/_common.sh" +grep -q mxtk_port_holder "$O2/bin/_common.sh" && ! grep -q 'mxtest_left\|mxtk_pid_is_mxcli_run' "$O2/bin/_common.sh" \ + && ok "[setup] _common.sh with the port helpers, without the mxtest/pid helpers" || bad "[setup] could not strip only the mxtest/pid helpers" +OUT="$(restart "$O2" --dry-run)"; RC=$? +[ "$RC" -eq 2 ] && ok "exit 2" || bad "exit $RC: $(printf '%s' "$OUT" | tail -2)" +has "names what is missing" "$OUT" "no mxtest_left" +has "names the remedy" "$OUT" "sync-project.sh" +hasnt "no command-not-found mid-run" "$OUT" "command not found" + echo "" echo "PASS=$PASS FAIL=$FAIL" [ "$FAIL" -eq 0 ] diff --git a/tests/wave2/test-test-scratch.sh b/tests/wave2/test-test-scratch.sh index 19db6b92..927fc60c 100755 --- a/tests/wave2/test-test-scratch.sh +++ b/tests/wave2/test-test-scratch.sh @@ -11,7 +11,8 @@ # the copy leaves out .mxcli (the live app's run --local record), .git and deployment; the port # lock is held during the run and released after; a second run while the first holds the ports # is refused; a dead holder's port lock is taken over; a live exec.sh on the project refuses the -# copy; --attach and -p are refused; mxcli's exit code is passed through; the copy is deleted. +# copy; --attach and -p are refused; mxcli's exit code is passed through; the copy is deleted; +# a bin/_common.sh older than the shared mxtest helpers stops with exit 2 and the sync remedy. # No real mxcli: a stub records its cwd and argv. set -uo pipefail TOOLKIT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -91,6 +92,17 @@ OUT="$(run g -- tests/a.test.mdl --attach)"; RC=$? OUT="$(run h -- tests/a.test.mdl -p Other.mpr)"; RC=$? [ "$RC" -eq 2 ] && ok "-p refused" || bad "-p exit $RC" +echo "-- a bin/_common.sh older than the shared mxtest helpers → exit 2 naming sync, nothing copied or run" +OC="$WORK/oldcommon"; mkdir -p "$OC/bin"; cp "$P/Fixture.mpr" "$P/mxcli" "$OC/"; cp "$P/bin/test-scratch.sh" "$OC/bin/" +sed '/^# mxcli run \/ test identity/,$d' "$TOOLKIT/project-bin/_common.sh" > "$OC/bin/_common.sh" +grep -q '^find_mpr()' "$OC/bin/_common.sh" && ! grep -q 'mxtest_left' "$OC/bin/_common.sh" \ + && ok "[setup] _common.sh without the mxtest helpers" || bad "[setup] could not strip only the mxtest helpers" +OUT="$( cd "$OC" && REC="$WORK/rec.oc" ./bin/test-scratch.sh -- tests/a.test.mdl 2>&1 )"; RC=$? +[ "$RC" -eq 2 ] && ok "exit 2" || bad "exit $RC: $(printf '%s' "$OUT" | tail -2)" +has "names the remedy" "$OUT" "sync-project.sh" +case "$OUT" in *"command not found"*) bad "died on command not found" ;; *) ok "no command-not-found" ;; esac +[ -f "$WORK/rec.oc" ] && bad "mxcli ran" || ok "nothing run" + echo "-- #304 review: a stale-lock takeover never removes a lock another run took meanwhile" # Deterministic replay of the race: the functions are lifted from the subject, and holder_live