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..661ed472 100644 --- a/src/doc.sh +++ b/src/doc.sh @@ -9,58 +9,49 @@ 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. The bracket class + # uses the POSIX []][ idiom: busybox awk (Alpine) rejects + # backslash-escaped brackets inside a bracket expression. + 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" +}