From 67bc9b7665abbfb3763f24bbf0c163a15094aaa5 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Thu, 16 Jul 2026 05:15:46 +0200 Subject: [PATCH] docs: add fork-budget rule with census method and measured limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records how PRs #801-#811 measured forks (PATH-shim census as ground truth; bash -x traces overcount via re-echoed test output), the replacement patterns that worked, and — equally important — the measured dead ends (bash loops lose to awk beyond ~100 lines, ${var//} is quadratic on bash 3.2, single-file build is not faster, tput width is snapshot-load-bearing). Auto-loads when editing src/ so future perf work starts from evidence instead of re-profiling. --- .claude/rules/perf-fork-budget.md | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .claude/rules/perf-fork-budget.md diff --git a/.claude/rules/perf-fork-budget.md b/.claude/rules/perf-fork-budget.md new file mode 100644 index 00000000..03918d62 --- /dev/null +++ b/.claude/rules/perf-fork-budget.md @@ -0,0 +1,80 @@ +--- +paths: + - "src/**/*.sh" + - "tests/acceptance/bashunit_*forks*_test.sh" +--- + +# Performance: Fork Budget & Census Method + +bashunit's dominant runtime cost on Bash 3.2 (macOS) is **process forks**, not +shell execution. A fork costs ~1-3ms; the acceptance suite spawns ~258 nested +`./bashunit` runs, so one avoidable fork per file or per test multiplies fast. +PRs #801-#811 removed the per-test and cold-start forks; this file records how +to measure and the traps found, so future work starts from evidence. + +## Measuring: shim census is ground truth, traces overcount + +**PATH-shim census (authoritative).** Wrap a binary, count invocations: + +```bash +real_awk="$(command -v awk)" # resolve BEFORE prepending the shim dir +printf '#!/usr/bin/env bash\necho x >> /tmp/count\nexec "%s" "$@"\n' "$real_awk" > shim/awk +chmod +x shim/awk +PATH="shim:$PATH" ./bashunit --no-parallel fixture_test.sh +grep -c . /tmp/count +``` + +Caveats: binaries pinned at startup via `command -v` (`$GREP`, `$MKTEMP`, +`$CAT`) bypass PATH shims — trace those instead; and shims are unreliable on +Git Bash (skip such tests on Windows). + +**`bash -x` trace census (cheap but inflated).** `PS4='+ ' bash -x ./bashunit …` +also counts trace lines **re-echoed inside captured test output**, so it can +overcount 10-20x (one real `grep` appeared 24 times). Use it to *locate* fork +sites (`PS4='+X ${BASH_SOURCE##*/}:$LINENO> '`), never to *count* them. It also +misses forks inside `--parallel` workers. Anchor real execs with +`grep -cE '^\++ +/[^ ]*mktemp$'`-style patterns (absolute path, exact argv +shape) to skip `command -v` probes and variable assignments. + +**Regression tests.** Budgets are enforced in +`tests/acceptance/bashunit_coldstart_forks_test.sh` (cold start: mktemp/perl/ +grep/mkdir) and `tests/acceptance/bashunit_run_forks_test.sh` (run path: +grep/cat/sed/sort/awk budget, run-dir cleanup). Extend these when you remove a +fork class; they are the RED test of the TDD cycle. + +## Replacement patterns that worked + +| Fork | Replacement | Example | +|------|-------------|---------| +| `echo x \| grep -c fixed` | `case` glob match | #803, #804 | +| `cat <>` creation | #801, #811 | +| probe fork + first-use fork | probe does the real work and seeds the return slot | #802 (clock perl) | + +`shopt -s extdebug` for `declare -F`: enable it **inside the capture subshell +only** — toggling it in the caller's shell clobbers caller state (#808). + +## Where pure bash LOSES — measured, do not "optimize" these + +- **`… | awk` over ≳100 lines**: a `while read` loop was 5x slower than the awk + fork (19ms vs 3.5ms for 600 `declare -F` lines). File scans (provider map, + duplicate check) stay awk. +- **`${var//pattern/}` on large strings**: quadratic on Bash 3.2 — 2.7 s where + awk takes 3.5ms. Never string-replace over big captures. +- **Regex assertions** (`assert_matches`): Bash 3.0 has no `[[ =~ ]]`; the + `grep -E` fork is mandatory. +- **Single-file build artifact**: sourcing `bin/bashunit` is *not* faster than + sourcing `src/*.sh` (parse time dominates, file opens don't). +- **`tput cols` at startup**: returns 80 on non-tty; snapshots depend on that + width. Not removable. + +## Current budgets (plain 1-test file run, Bash 3.2 macOS) + +~3 `awk` (provider map ×2 — the counting subshell can't share its cache with +the runner — plus the duplicate check), 1 `perl` ×2 clock reads (start/end; +no `EPOCHREALTIME` before Bash 5), 1 `base64` capability probe, 1 `mkdir`, +1 `tput`. Per-test cost is fork-free. Cold start ~50ms, ~31ms of which is +sourcing `src/` (irreducible without lazy-loading, rejected in #798).