diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e5509b..074ca955 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 +- new(doctor, speed-setup): **`bin/doctor.sh --bench []`** times 50 process starts, git status, python/node start, one gate-check, an mxcli check and (on a COPY of the model) mx check against measured reference constants, prints one verdict line and writes `.claude/loop/bench-.tsv` for comparing machines; a **Machine health** section (normal run and `--bench`) warns on leftover mxbuild `deno` helpers and free memory under 2 GB (mxbuild is killed with exit 137 when memory runs out), `--cleanup` stops exactly those helpers after a typed y (no tty: prints the command); both runs end with ONE **Recommended fix** block (Gate mode: check, else WSL, dev container, or Claude in the cloud; antivirus-exclusion advice removed, it needed IT); new `skills/speed-setup.md`. Field run: ab-581/gk on Linux 4 cores, 46 orphan deno helpers found, bench 10.4 s vs 10.9 s reference — Maurits (mauritscgvisser) - perf(exec): **~55% fewer forks and ~60% fewer process starts per exec** (Windows: ~100 ms each; follow-up to #584/#585). Measured on a two-module Purchasing/Approval app (Mendix 11.13, `Gate mode: check`), Linux, `strace -f` counting clone+execve for one `./bin/exec.sh`: forks 733 → 335, execs 350-375 → 135, 3 runs each; verdicts, exit codes, output and the BUILD-LOG row are byte-identical. What changed: **exec.sh** resolves the model, platform, Python and toolkit root once and hands them down as exported memos (`MXTK_MPR_MEMO`, `MXTK_MODEL_DIR_MEMO`, `MXTK_PLATFORM`, `MXTK_PY_MEMO`), uses `EPOCHSECONDS` / `printf %(…)T` / `${x##*/}` instead of `date` / `basename` / `dirname`, reads PROJECT.md and CLAUDE.local.md fields in-shell, and parses lint-gate's output with ONE awk instead of ~10 grep/sed/tr/sort/paste; **_common.sh** waits for an unbounded gate with `wait` instead of a `sleep 1` process per second (10 on a warm build, 150+ on a cold Windows one), reads the model version with one Python start instead of two, and no longer starts basename/cut/sed/grep to match a Studio Pro folder to a version; **model-stamp.sh** asks git once (it asked 6 times), keeps the file list in an array instead of mktemp/paste/rm, reads the stamp once, and `clear` answers before any set-up; **snapshot-mpr.sh**, **look-ledger.sh**, **script-owners.sh**, **metrics-ledger.sh**, **row-groups.sh**, **build-plan-status.sh**, **lint-gate.sh**, **exec-approval.sh**, **mdl-scan.sh** drop their dirname/basename/date/ls|wc|tr/find|wc forks; lint-gate skips its rule-receipt Python while no rule file, receipt or disabled-list is newer than the last clean check. `MXTK_PROFILE=2` traces the scripts exec.sh starts as well (=1 counts only exec.sh's own commands, so a started script is one line there), and the profile lines now name the file. Reported by mkr87. — mkr87 (#584, #585) - new(doctor, init): **`bin/doctor.sh ` measures performance, not just presence**: a "Performance (measured)" section reads the last 20 `steps:` rows of the project's BUILD-LOG and reports the median exec time, the median gate and the slowest step against a normal machine (17-34 s per exec, gate 9-20 s), warning above 60 s per exec or 45 s gate with the lever to pull; on Windows with slow process starts it recommends (never requires) WSL2 or a dev container (Docker Engine in WSL2, Rancher Desktop, Podman Desktop). `Gate mode: check` is the default for every project, existing ones included after sync (`Gate mode: full` in PROJECT.md keeps a full build per exec). Windows field run (ExpenseTracker, Windows 11, Git Bash, 103 ms per process start): check-only execs took 90-103 s against 138-139 s with the full build (gate 60 s → 12-14 s); the 60-70 s of non-gate overhead is the next target. Reported by mkr87. — mkr87 (#584, #585) - new(exec): **check-only gate by default (`Gate mode: full` in PROJECT.md, or `MXTK_GATE_MODE=full`, opts out)**: each exec runs `mx check` (beside mxbuild, same version) instead of the full deploy build, converting its JSON into mxbuild's errors shape so the delta gate, rollback and report are unchanged. The full build still runs when Java/JavaScript/theme/widgets changed since the last full build, when the project has Java actions and the script drops or renames something (`drop … if exists`, the re-create idiom, does not count), on `--patch`, before any full build is recorded, and after 10 check-only passes in a row (`MXTK_CHECK_MAX`). The other full builds sit at the work's natural boundaries, as mxcli itself advises (`mx check` after every exec, mxbuild when packaging): a new pre-push hook and `module-close.sh` run `bin/verify-model.sh` when the stamp says the model was only `mx check`ed (the stamp now records `kind: check|full`; `model-stamp.sh check --full`), and `bin/verify-model.sh` closes a batch on demand. Commits stay allowed on a check stamp. Field run (purchase-requests app, Linux, Mendix 11.13.0): gate 20 s → 9-10 s; a push after check-only execs ran the full build (20 s) and re-stamped the model as full; a dropped attribute caught as 6 × CE1613 by `mx check` on the broken model. Golden input: captured `mx check -j` output in `tests/wave2/fixtures/mx-check/`. For #584/#585 (Windows, mkr87). — mkr87 (#584, #585) diff --git a/README.md b/README.md index 421ddfb2..20a9cd06 100644 --- a/README.md +++ b/README.md @@ -782,6 +782,7 @@ Every mxcli project has a `.ai-context/skills/` directory (bundled by `mxcli ini | Needing Studio Pro load evidence without a human at the GUI — direct-binary launch and log capture; a capture technique, NOT a validated pass/fail oracle | `skills/scriptable-sp-verification.md` | | A runtime test or seed reads/writes data that then is not there (the test wrote a row, the DB query shows nothing), or vice versa — three local Postgres instances can answer on this box; resolve the real port from the project's own compose file first | `skills/learned-local-db-confusion.md` | | gate-check.sh reports a Stage 0 file not found that plainly exists — ANALYSIS_BASE falls back to project root until Stage 1; move the file, do not debug the script | `skills/gate-check-file-locations.md` | +| The user says the toolkit, an exec or the gate is slow, or asks to update the toolkit, run doctor and check whether it can be faster on their machine (werk de toolkit bij, draai doctor, controleer of het sneller kan) | `skills/speed-setup.md` | **Reference — lookup tables, not method** diff --git a/ROUTING.md b/ROUTING.md index cd2c6203..4c0518aa 100644 --- a/ROUTING.md +++ b/ROUTING.md @@ -270,6 +270,7 @@ picks the row up. That is the whole procedure — there is no second list to rem | gate-check.sh reports a Stage 0 file not found that plainly exists — ANALYSIS_BASE falls back to project root until Stage 1; move the file, do not debug the script | `skills/gate-check-file-locations.md` | ba,gate | 0 | ondemand | | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | `bin/bug-lookup.sh` | mdl,gate | 5,6 | baseline | | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, is filed only by a human with `!` (`--file PBUG-NN`, y/N, denied to agents), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | mdl,gate | 5,6 | baseline | +| The user says the toolkit, an exec or the gate is slow, or asks to update the toolkit, run doctor and check whether it can be faster on their machine (werk de toolkit bij, draai doctor, controleer of het sneller kan) | `skills/speed-setup.md` | lead | - | ondemand | #### Reference — lookup tables, not method diff --git a/bin/doctor.sh b/bin/doctor.sh index a537ab9a..e5aab555 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -37,6 +37,16 @@ # # that drifted mid-project (new binary, wrong-arch # # mxbuild, a Defender policy) read as "the toolkit # # is broken". +# bin/doctor.sh --bench [] # ~1 minute, changes nothing: times 50 process starts, git status, +# # python/node start, one gate-check, an mxcli check of a tiny script +# # and (on a COPY of the model) mx check; prints step | this machine | +# # reference | factor and one verdict line, and writes the numbers to +# # /.claude/loop/bench-.tsv to compare with colleagues. +# bin/doctor.sh --cleanup # list leftover mxbuild helper processes (deno from modeler/tools/deno, +# # older than 10 min, no live mxbuild parent) and stop exactly those after +# # you type y. With no terminal it prints the command and stops nothing. +# # Every normal run also has a "Machine health" section (same leftovers +# # plus free memory): mxbuild is killed with exit 137 when memory runs out. # bin/doctor.sh --gate-selftest [] # force the gate self-test section to run even # # under --quick, or on its own. See its own header # # (search "Gate self-test") for what it proves and @@ -82,6 +92,8 @@ INSTALL=0 ASSUME_YES=0 QUICK=0 GATE_SELFTEST=0 +BENCH=0 +CLEANUP=0 PS_MODE="" PS_IF_STALE=0 NO_DOCKER="${MXTK_DOCTOR_SKIP_DOCKER:-0}" @@ -91,6 +103,8 @@ for _arg in "$@"; do --install) INSTALL=1 ;; --yes|-y) ASSUME_YES=1 ;; --quick) QUICK=1 ;; + --bench) BENCH=1 ;; + --cleanup) CLEANUP=1 ;; --gate-selftest) GATE_SELFTEST=1 ;; --no-docker) NO_DOCKER=1 ;; --property-sets) PS_MODE=check ;; @@ -411,6 +425,429 @@ if [ -n "$PS_MODE" ]; then exit "$(ps_exit_code)" fi +# recommended_fix — ONE block that ends both the normal run and --bench: the first option that works on THIS +# machine, in this order. Read-only, cheap probes; never installs anything and never asks for IT's help. +# a. PROJECT.md says `Gate mode: full` -> remove that line: `mx check` per exec is the default, with the +# full build before every push (offered even when nothing is slow: it helps every machine). +# b. process starts are slow AND this is Git Bash AND `wsl.exe --status` succeeds -> run the toolkit in WSL. +# c. slow AND a docker/podman CLI is present and runs (`--version`, no daemon call) -> a dev container. +# d. slow, none of the above -> let Claude work in the cloud (Claude Code on the web), pull with git pull. +# "slow" = RF_SPAWN_MS (ms per process start) over MXTK_SPAWN_WARN_MS (30), set by the caller. +RF_SPAWN_MS="" +recommended_fix() { + head_ "Recommended fix" + _rf_slow=0 + [ -n "$RF_SPAWN_MS" ] && [ "$RF_SPAWN_MS" -gt "${MXTK_SPAWN_WARN_MS:-30}" ] && _rf_slow=1 + _rf_pm="" + if [ -n "$PROJECT_DIR" ] && [ -f "$PROJECT_DIR/PROJECT.md" ]; then + _rf_pm="$(sed -n 's/^[*_ ]*Gate mode:[*_ ]*\([A-Za-z]*\).*/\1/p' "$PROJECT_DIR/PROJECT.md" 2>/dev/null | head -1 | tr 'A-Z' 'a-z')" + if [ "$_rf_pm" = full ]; then + echo " Remove \"Gate mode: full\" from PROJECT.md (one line). Each model write is then checked with the quick" + echo " mx check instead of a full build, and the full build runs before you push and at module close." + return 0 + fi + fi + if [ "$_rf_slow" = 0 ] && [ "${MH_BAD:-0}" = 1 ]; then + echo " Free up memory first (see Machine health): bin/doctor.sh --cleanup stops leftover mxbuild helpers," + echo " asking before it stops anything. Process starts are not slow here${RF_SPAWN_MS:+ ($RF_SPAWN_MS ms each)}." + return 0 + fi + if [ "$_rf_slow" = 0 ]; then + echo " None needed: process starts are not slow here${RF_SPAWN_MS:+ ($RF_SPAWN_MS ms each)}." + return 0 + fi + if [ "$MH_OS" = gitbash ] && command -v wsl.exe >/dev/null 2>&1; then + _rf_ok=0 + if command -v timeout >/dev/null 2>&1; then timeout 10 wsl.exe --status >/dev/null 2>&1 && _rf_ok=1 + else wsl.exe --status >/dev/null 2>&1 && _rf_ok=1; fi + if [ "$_rf_ok" = 1 ]; then + echo " Run the toolkit in WSL (already on this machine). Process starts there are cheap, and the project" + echo " folder should live inside the WSL filesystem, not under /mnt/c." + return 0 + fi + fi + _rf_rt="${MXTK_CONTAINER_RUNTIME:-}" + if [ -z "$_rf_rt" ]; then + if command -v docker >/dev/null 2>&1; then _rf_rt=docker; elif command -v podman >/dev/null 2>&1; then _rf_rt=podman; fi + fi + if [ -n "$_rf_rt" ] && "$_rf_rt" --version >/dev/null 2>&1; then + echo " Use a dev container ($_rf_rt is installed here). Process starts inside it are cheap." + return 0 + fi + echo " Let Claude work in the cloud (Claude Code on the web), then pull the result into Studio Pro with git pull." + echo " Nothing needs installing on this machine, and nobody's permission is needed." +} + +# --- machine health (leftover mxbuild helpers, free memory) -- and --cleanup / --bench --------------- +# WHY. Field, 2026-10-29, Linux 4 cores / 16 GB: mxbuild kept dying with exit 137. 47 orphaned +# `modeler/tools/deno` processes (parent PID 1, up to 37 min old, ~270 MB each, 13 GB in total) had eaten the +# memory. mxbuild starts one deno helper per run; when mxbuild is killed (gate timeout, `kill`, the +# out-of-memory killer itself) the helper outlives it, and every orphan makes the next kill more likely. +# A run that ends normally leaves none (checked, tools/deno helper gone within a second). +# +# A "leftover" is: a process whose program path contains modeler/tools/deno (deno / deno.exe), older than +# MXTK_LEFTOVER_MIN_AGE_S (default 600 s), whose parent is NOT a live mxbuild / mx / dotnet / Studio Pro. +# The parent test is why a long but healthy build (a big model builds longer than 10 minutes) is never +# listed, and so never stopped by --cleanup. Golden input: tests/wave2/fixtures/doctor-bench/ (a real +# `ps -eo pid,ppid,etimes,rss,args` capture that contains one live helper and four orphans). +# Platforms: Linux/WSL: ps etimes + /proc/meminfo. macOS: ps etime + vm_stat. Git Bash: wmic (tasklist as a +# count-only fallback). The Git Bash branch is written from wmic's documented /format:list output and has not +# been run on Windows; the Linux branch is the one field-proven here. +MH_MIN_AGE="${MXTK_LEFTOVER_MIN_AGE_S:-600}" +MH_LOW_MB="${MXTK_LOW_MEMORY_MB:-2048}" +case "$(uname -s 2>/dev/null)" in + Darwin) MH_OS=macos ;; + MINGW*|MSYS*|CYGWIN*) MH_OS=gitbash ;; + *) MH_OS=linux ;; +esac + +# mh_rows — one line per process: pid ppid age-or-etime rss_kb program args... +mh_rows() { + case "$MH_OS" in + macos) ps -axo pid=,ppid=,etime=,rss=,args= 2>/dev/null ;; + gitbash) + command -v wmic >/dev/null 2>&1 || return 1 + wmic process where "name='deno.exe' or name='mxbuild.exe' or name='mx.exe' or name='dotnet.exe'" \ + get CommandLine,CreationDate,Name,ParentProcessId,ProcessId,WorkingSetSize /format:list 2>/dev/null \ + | tr -d '\r' | awk ' + function flush() { if (pid != "") { age = (cd != "") ? systime() - mktime(substr(cd,1,4) " " substr(cd,5,2) " " substr(cd,7,2) " " substr(cd,9,2) " " substr(cd,11,2) " " substr(cd,13,2)) : 0 + print pid, ppid, age, int(ws / 1024), nm, cl } + pid = ppid = cd = nm = cl = ""; ws = 0 } + { i = index($0, "="); if (i == 0) { if ($0 == "") flush(); next } + k = substr($0, 1, i - 1); v = substr($0, i + 1) + if (k == "ProcessId") pid = v; else if (k == "ParentProcessId") ppid = v + else if (k == "CreationDate") cd = v; else if (k == "Name") nm = v + else if (k == "WorkingSetSize") ws = v; else if (k == "CommandLine") cl = v } + END { flush() }' ;; + *) ps -eo pid,ppid,etimes,rss,args 2>/dev/null ;; + esac +} + +# mh_leftovers — the leftover helpers, one line each: pid age_seconds rss_kb +mh_leftovers() { + mh_rows | awk -v min="$MH_MIN_AGE" ' + function secs(t, a, p, n, d) { + if (t !~ /[:-]/) return t + 0 + d = 0; if (index(t, "-")) { split(t, a, "-"); d = a[1]; t = a[2] } + n = split(t, p, ":"); return d * 86400 + ((n == 3) ? p[1] * 3600 + p[2] * 60 + p[3] : p[1] * 60 + p[2]) } + $1 !~ /^[0-9]+$/ { next } + { line = $0; gsub(/\\/, "/", line) + pid = $1; pp[pid] = $2; age[pid] = secs($3); rss[pid] = $4; first[pid] = $5; all[pid] = line; ord[++n] = pid } + END { for (i = 1; i <= n; i++) { p = ord[i]; f = first[p]; gsub(/\\/, "/", f) + if (!(f ~ /modeler\/tools\/deno\/.*\/deno$/ || (f == "deno.exe" && all[p] ~ /modeler\/tools\/deno/))) continue + if (age[p] < min) continue + q = pp[p] + if (q in first) { g = first[q]; gsub(/\\/, "/", g); sub(/.*\//, "", g) + if (g ~ /^(mxbuild|mx|mxutil|dotnet|MxBuild)(\.exe)?$/ || g ~ /[Ss]tudio/) continue } + print p, age[p], rss[p] } }' +} + +# mh_free_mb — megabytes of memory a new process could get, or nothing when this platform cannot say. +mh_free_mb() { + case "$MH_OS" in + macos) vm_stat 2>/dev/null | awk '/page size of/ { for (i = 1; i <= NF; i++) if ($i == "of") ps = $(i + 1) } + /^Pages free:|^Pages inactive:|^Pages speculative:/ { gsub(/\./, "", $NF); pg += $NF } + END { if (ps > 0 && pg > 0) print int(pg * ps / 1048576) }' ;; + gitbash) command -v wmic >/dev/null 2>&1 || return 0 + wmic OS get FreePhysicalMemory /value 2>/dev/null | tr -d '\r' | awk -F= '$1 == "FreePhysicalMemory" && $2 + 0 > 0 { print int($2 / 1024) }' ;; + *) awk '/^MemAvailable:/ { a = $2 } /^MemFree:/ { f = $2 } /^Buffers:/ { b = $2 } /^Cached:/ { c = $2 } + END { if (a > 0) print int(a / 1024); else if (f > 0) print int((f + b + c) / 1024) }' "${MXTK_PROC_MEMINFO:-/proc/meminfo}" 2>/dev/null ;; + esac +} + +# mh_fmt_mb — "1.9 GB" / "640 MB" into MH_FMT (no subshell: a process start costs 100 ms on Windows). +mh_fmt_mb() { + if [ "$1" -ge 1024 ]; then MH_FMT="$(( $1 * 10 / 1024 / 10 )).$(( $1 * 10 / 1024 % 10 )) GB"; else MH_FMT="$1 MB"; fi +} + +# advise — prints like warn but does not count toward the exit code: machine health is about this +# machine's state right now, not about the toolkit install, and doctor's exit code is read by +# callers (#591 review). session-check still shows the line, because it greps for WARN. +advise() { printf ' WARN %s\n' "$*"; } +MH_BAD=0 +machine_health() { + head_ "Machine health" + _mh_free="$(mh_free_mb)" + _mh_can=1 + if [ "$MH_OS" = gitbash ]; then command -v wmic >/dev/null 2>&1 || _mh_can=0; else command -v ps >/dev/null 2>&1 || _mh_can=0; fi + _mh_left=""; [ "$_mh_can" = 1 ] && _mh_left="$(mh_leftovers)" + _mh_n=0; _mh_kb=0; _mh_old=0 + if [ -n "$_mh_left" ]; then + _mh_sum="$(printf '%s\n' "$_mh_left" | awk '{ n++; kb += $3; if ($2 > old) old = $2 } END { print n, kb, old }')" + set -- $_mh_sum; _mh_n=$1; _mh_kb=$2; _mh_old=$3 + fi + _mh_bad=0 + if [ -z "$_mh_free" ]; then + note "free memory: not measured on this platform" + elif [ "$_mh_free" -lt "$MH_LOW_MB" ]; then + mh_fmt_mb "$_mh_free"; advise "free memory: $MH_FMT (under $((MH_LOW_MB / 1024)) GB)"; _mh_bad=1 + else + mh_fmt_mb "$_mh_free"; ok "free memory: $MH_FMT" + fi + if [ "$_mh_n" -gt 0 ]; then + mh_fmt_mb "$((_mh_kb / 1024))" + advise "$_mh_n leftover mxbuild helper process(es) (deno), $MH_FMT in total, oldest $((_mh_old / 60)) min" + _mh_bad=1 + elif [ "$_mh_can" = 0 ]; then + note "leftover mxbuild helpers: not checked (this machine has no ps / wmic to list processes)" + else + ok "no leftover mxbuild helper processes" + fi + MH_BAD="$_mh_bad" + if [ "$_mh_bad" = 1 ]; then + note "When memory runs out the operating system kills mxbuild (exit 137), and the build gate then reads" + note "as a failure that is not about your model. Each mxbuild that is killed leaves its deno helper" + note "running, holding ~270 MB, so the problem grows by itself." + [ "$_mh_n" -gt 0 ] && note "Stop exactly those helpers (asks first): bin/doctor.sh --cleanup" + [ "$_mh_n" -eq 0 ] && note "Close other programs, or run the toolkit on a machine with more memory." + fi +} + +# --cleanup: list the leftovers machine_health counts, stop them ONLY after a typed y at a terminal. +if [ "$CLEANUP" = 1 ]; then + head_ "Cleanup: leftover mxbuild helpers (deno)" + if [ "$MH_OS" = gitbash ]; then command -v wmic >/dev/null 2>&1 || { bad "cannot list processes: wmic is missing. Use Task Manager (deno.exe under modeler\\tools\\deno)."; exit 1; } + else command -v ps >/dev/null 2>&1 || { bad "cannot list processes: no ps on this machine."; exit 1; }; fi + _cl="$(mh_leftovers)" + if [ -z "$_cl" ]; then ok "nothing to clean up: no deno helper from modeler/tools/deno older than $((MH_MIN_AGE / 60)) min without a live mxbuild"; exit 0; fi + _cl_pids="$(printf '%s\n' "$_cl" | awk '{ printf "%s%s", sep, $1; sep = " " }')" + printf '%s\n' "$_cl" | awk '{ printf " pid %-7s %4d min %4d MB\n", $1, $2 / 60, $3 / 1024 }' + case "$MH_OS" in + gitbash) _cl_cmd="taskkill //F$(for _p in $_cl_pids; do printf ' //PID %s' "$_p"; done)" ;; + *) _cl_cmd="kill $_cl_pids" ;; + esac + if [ ! -t 0 ] || [ ! -t 1 ]; then + note "No terminal to ask on, so nothing was stopped. To stop exactly these yourself:" + note " $_cl_cmd" + exit 0 + fi + printf ' Stop these %s process(es)? Type y to confirm, anything else cancels: ' "$(printf '%s\n' "$_cl" | wc -l | tr -d ' ')" + _cl_ans=""; read -r _cl_ans || true + if [ "$_cl_ans" != "y" ] && [ "$_cl_ans" != "Y" ]; then note "Cancelled. Nothing was stopped."; exit 0; fi + # Re-read the list: only a pid that is STILL a leftover is touched (pids get reused). + _cl_now=" $(mh_leftovers | awk '{ printf "%s ", $1 }')" + _cl_done=0 + for _p in $_cl_pids; do + case "$_cl_now" in *" $_p "*) ;; *) note "pid $_p is gone or no longer a leftover: left alone"; continue ;; esac + if [ "$MH_OS" = gitbash ]; then taskkill //F //PID "$_p" >/dev/null 2>&1 || true; else kill "$_p" 2>/dev/null || true; fi + _cl_done=$((_cl_done + 1)) + done + sleep 2 + _cl_left=" $(mh_leftovers | awk '{ printf "%s ", $1 }')" + _cl_alive="" + for _p in $_cl_pids; do case "$_cl_left" in *" $_p "*) _cl_alive="$_cl_alive $_p" ;; esac; done + ok "asked $_cl_done process(es) to stop" + if [ -n "$_cl_alive" ]; then + warn "still running after 2 s:$_cl_alive" + note "Not forced. If you want them gone anyway: kill -9$_cl_alive" + fi + exit 0 +fi + +# --bench: ~1 minute, changes nothing in the project. Times the things every toolkit script is made of, +# against this table's reference column, so "the toolkit is slow here" becomes a number a colleague can +# compare. Writes /.claude/loop/bench-.tsv (one row per step, concatenable). +# +# REFERENCE (BENCH_REF_*, milliseconds): measured on 2026-10-29 on Linux 6.18 (Firecracker VM, 4 cores, +# 16 GB, local disk), project = a copy of the purchase-requests test app (PurchaseApp, mxcli v0.28.0, mx 11.13.0, git with 6.5k +# files): the median of 5 `bin/doctor.sh --bench` runs. Honest caveat: the machine was not freshly idle; it +# held 46 leftover deno helpers and ~1.4 GB free memory during those runs (CPU-bound steps do not care, and +# no step swapped: there is no swap), so treat the reference as "a Linux box, a bit loaded", not a lab figure. +# For scale, a Windows 11 / Git Bash laptop in the field measured 103 ms per process start against ~2 ms +# here: that one number is what makes every step below that is built from process starts (git, gate-check, +# mxcli, the doctor itself) many times slower there. +BENCH_REF_SPAWN50_MS=86 +BENCH_REF_GIT_MS=34 +BENCH_REF_PY_MS=28 +BENCH_REF_NODE_MS=74 +BENCH_REF_GATE_MS=3237 +BENCH_REF_MXCLI_MS=606 +BENCH_REF_MX_MS=6902 +BENCH_FIELD_SPAWN_MS=103 +BENCH_MX_MIN_MB="${MXTK_BENCH_MX_MIN_MB:-1024}" + +# b_clock — sets B_MS to a millisecond clock reading, or "" when this shell has none. EPOCHREALTIME (bash 5, +# Git Bash) costs no process; `date +%s%N` does (GNU date only: macOS prints a literal N, bash 3.2 has neither). +b_clock() { + B_MS="" + if [ -n "${EPOCHREALTIME:-}" ]; then + B_MS="${EPOCHREALTIME/[.,]/}"; B_MS=$((B_MS / 1000)); return 0 + fi + _bc="$(date +%s%N 2>/dev/null || true)" + case "$_bc" in ""|*[!0-9]*) return 0 ;; esac + B_MS=$((_bc / 1000000)) +} + +# b_bounded — run with output discarded, stdin closed, killed after . B_RC = exit +# code, 124 on timeout. No timeout(1) assumed (macOS has none). Polls every 0.2 s. +b_bounded() { + _bb_max=$(( $1 * 5 )); shift + "$@" >/dev/null 2>&1 < /dev/null & + _bb_pid=$!; _bb_w=0 + while kill -0 "$_bb_pid" 2>/dev/null; do + if [ "$_bb_w" -ge "$_bb_max" ]; then kill "$_bb_pid" 2>/dev/null; wait "$_bb_pid" 2>/dev/null; B_RC=124; return 0; fi + sleep 0.2 2>/dev/null || sleep 1 + _bb_w=$((_bb_w + 1)) + done + B_RC=0; wait "$_bb_pid" || B_RC=$? +} + +b_fmt_ms() { + if [ "$1" -ge 10000 ]; then B_FMT="$(( $1 / 1000 )).$(( $1 % 1000 / 100 )) s" + elif [ "$1" -ge 1000 ]; then B_FMT="$(( $1 / 1000 )).$(( $1 % 1000 / 10 / 10 ))$(( $1 % 1000 / 10 % 10 )) s" + else B_FMT="$1 ms"; fi +} + +# b_row