From bf82f1391f4d9251332e11c2cf8cae1f814526db Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 19 Jul 2026 16:47:34 +0200 Subject: [PATCH 1/2] perf(doc): parse assertion docs in a single awk pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bashunit::doc::print_asserts looped over all ~1.6k lines of docs/assertions.md and forked an 'echo | sed' pipe per line to detect headings, plus another sed per printed docstring line — ~3200 forks and ~5.1s wall to print a text file. The acceptance suite paid it ~7 times (snapshot + exit-code pairs and built-binary comparisons), making bashunit_test.sh a 29.7s file. One awk pass carries the identical logic (assert*/bashunit* heading gate, literal substring filter, bracket/anchor stripping, code-fence docstring emission): output is byte-identical (shasum-verified for 'doc' and 'doc equals', dev and built binary) and runs in ~50ms. bashunit_test.sh 29.7s -> 7.8s; full parallel suite ~24.5s -> ~16.5s. Fork-census regression test added (0 sed forks on the doc path). Closes #832 --- CHANGELOG.md | 1 + src/doc.sh | 87 +++++++++------------ tests/acceptance/bashunit_doc_forks_test.sh | 23 ++++++ 3 files changed, 62 insertions(+), 49 deletions(-) create mode 100644 tests/acceptance/bashunit_doc_forks_test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 388fe91a..fc2fa7a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - `--jobs auto` / `-j auto` caps parallel concurrency at the CPU core count (portable across Linux/macOS/BSD); the default stays unlimited (#766) ### Changed +- `bashunit doc` no longer forks an `echo | sed` pipe per line of the assertion docs: a single awk pass prints the same bytes in ~50ms instead of ~5s (#832) - Multi-file runs are no longer quadratic in file count: each file's test functions are unset once the file has been processed, so test subshells stop forking an ever-growing shell. bashunit's own 63-file unit suite: ~64s -> ~22s sequential, ~26s -> ~7s parallel (#829) - Major performance work with no behaviour change: assertions, per-test execution, per-file discovery, cold start and parallel result publishing are now (near) fork-free, snapshots and `--tag` scans are cached, and quadratic failure rendering is single-pass. Benchmarks on bash 3.2: 100x10 `assert_equals` ~1.50s -> ~0.76s, 500 snapshot assertions ~7.5s -> ~3.0s, 100 tagged tests ~2.92s -> ~0.68s, and bashunit's own acceptance suite ~61s -> ~17s (#761-#764, #772-#775, #798, #801-#807, #809, #810, #813, #817) - Per-test timing now defaults to `auto` (`BASHUNIT_SHOW_EXECUTION_TIME=true|false|auto`): shown only when the clock is fork-free, avoiding `perl` forks on bash 3.2; `--profile`/`--verbose`/reports still measure (see `adrs/adr-008-auto-skip-per-test-timing.md`) (#765) diff --git a/src/doc.sh b/src/doc.sh index 21f6b2bf..1a8ff6cd 100644 --- a/src/doc.sh +++ b/src/doc.sh @@ -9,58 +9,47 @@ function bashunit::doc::get_embedded_docs() { # __BASHUNIT_EMBEDDED_DOCS_END__ } +# Single awk pass over the embedded docs: the previous line-by-line shell loop +# forked an `echo | sed` pipe per line (~3.2k forks, ~5s for the ~1.6k-line +# docs page); one awk fork does the same work in milliseconds (#832). function bashunit::doc::print_asserts() { local filter="${1:-}" - local docstring="" - local fn="" - local should_print=0 - local line - while IFS='' read -r line || [ -n "$line" ]; do - fn=$(echo "$line" | sed -n 's/^## \([A-Za-z0-9_]*\).*/\1/p') - # Only treat assertion headings as doc entries; skip prose sections - # like "## Related" that are part of the documentation page but not asserts. - case "$fn" in - assert* | bashunit*) ;; - *) fn="" ;; - esac - if [ -n "$fn" ]; then - local _match=0 - if [ -z "$filter" ]; then - _match=1 - else - case "$fn" in *"$filter"*) _match=1 ;; esac - fi - if [ "$_match" -eq 1 ]; then - should_print=1 - echo "$line" - docstring="" - else - should_print=0 - fi - continue - fi + bashunit::doc::get_embedded_docs | awk -v filter="$filter" ' + { + if ($0 ~ /^## /) { + # Heading word: the leading [A-Za-z0-9_]* run after "## ". Only + # assert*/bashunit* headings are doc entries; prose headings like + # "## Related" fall through and are treated as regular content. + fn = substr($0, 4) + sub(/[^A-Za-z0-9_].*$/, "", fn) + if (fn ~ /^(assert|bashunit)/) { + if (filter == "" || index(fn, filter) > 0) { + should_print = 1 + print $0 + doc = "" + } else { + should_print = 0 + } + next + } + } - if ((should_print)); then - # Check for code fence using pattern matching instead of regex - # Avoids backtick escaping issues in Bash 3.0 - case "$line" in - '```'*) - echo "--------------" - echo "$docstring" - should_print=0 - continue - ;; - esac + if (should_print) { + if ($0 ~ /^```/) { + print "--------------" + print doc + should_print = 0 + next + } + if ($0 ~ /^::: code-group/) next - case "$line" in - "::: code-group"*) continue ;; - esac - - # Remove markdown link brackets and anchor tags - line="${line//[\[\]]/}" - line="$(sed -E 's/ *\(#[-a-z0-9]+\)//g' <<<"$line")" - docstring="$docstring$line"$'\n' - fi - done <<<"$(bashunit::doc::get_embedded_docs)" + # Remove markdown link brackets and anchor tags + line = $0 + gsub(/[\[\]]/, "", line) + gsub(/ *\(#[-a-z0-9]+\)/, "", line) + doc = doc line "\n" + } + } + ' } diff --git a/tests/acceptance/bashunit_doc_forks_test.sh b/tests/acceptance/bashunit_doc_forks_test.sh new file mode 100644 index 00000000..5c014c4c --- /dev/null +++ b/tests/acceptance/bashunit_doc_forks_test.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression guard for #832. `bashunit doc` used to fork an `echo | sed` pipe +# for every line of the ~1.6k-line assertions.md (plus another `sed` per +# printed docstring line): ~3200 forks and ~5s wall to print a text file. The +# doc path must parse the embedded docs in a single awk pass instead. +function test_doc_command_does_not_fork_sed_per_line() { + if bashunit::check_os::is_windows; then + bashunit::skip "process tracing is unreliable under Git Bash" && return + fi + + local trace + trace="$(PS4='+ ' bash -x ./bashunit doc equals 2>&1 >/dev/null)" + + # Count real `sed` process executions. The doc path invokes `sed` via PATH + # (not a pinned absolute binary), so the traced command token is a bare + # `sed` — match both forms; `command -v sed` is a builtin and never shows. + local sed_forks + sed_forks="$(printf '%s\n' "$trace" | grep -cE '^\++ +(/[^ ]*/)?sed ' || true)" + + assert_equals 0 "$sed_forks" +} From d814c6cf09fd13b57fba13e2e49976dd4dbdd249 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Sun, 19 Jul 2026 16:53:26 +0200 Subject: [PATCH 2/2] fix(doc): use POSIX bracket idiom for busybox awk busybox awk (Alpine) rejects backslash-escaped brackets inside a bracket expression, so the link-bracket gsub silently matched nothing and doc output kept its markdown brackets. The []][ idiom is the portable spelling; output stays shasum-identical on macOS/GNU awk. --- src/doc.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/doc.sh b/src/doc.sh index 1a8ff6cd..661ed472 100644 --- a/src/doc.sh +++ b/src/doc.sh @@ -44,9 +44,11 @@ function bashunit::doc::print_asserts() { } if ($0 ~ /^::: code-group/) next - # Remove markdown link brackets and anchor tags + # Remove markdown link brackets and anchor tags. The bracket class + # uses the POSIX []][ idiom: busybox awk (Alpine) rejects + # backslash-escaped brackets inside a bracket expression. line = $0 - gsub(/[\[\]]/, "", line) + gsub(/[][]/, "", line) gsub(/ *\(#[-a-z0-9]+\)/, "", line) doc = doc line "\n" }