diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ea68af..0c993a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- `--jobs auto` (and `-j auto`) caps parallel concurrency at the detected CPU core count, portable across Linux/macOS/BSD (`nproc`, then `sysctl`, then `getconf`, falling back to 4). The default stays unlimited (`--jobs 0`) (#766) + ### Changed - Faster test execution: removed avoidable subshell forks from the per-test hot path (runtime-error detection, subshell-output decode, name normalization, test-id generation, retry-count lookup, and the passing-test line are now fork-free), and per-test temp-file cleanup no longer forks `rm` when the test created none (`rm` 102 -> 2 on a 100-test file). No behaviour change (#764) - Per-test execution time now defaults to `auto` (`BASHUNIT_SHOW_EXECUTION_TIME=true|false|auto`): shown when the shell has a fork-free clock (Bash 5.0+, GNU `date`), hidden when measuring would fork an interpreter (e.g. Bash 3.2 on macOS, which falls back to `perl`). This removes ~2 `perl` forks per test on those shells. `--profile`, `--verbose`, reports, and `=true` still measure. See `adrs/adr-008-auto-skip-per-test-timing.md` (#765) diff --git a/docs/command-line.md b/docs/command-line.md index 9fcfa4b1..fc7f49b6 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -67,7 +67,7 @@ bashunit test tests/ --parallel --simple | `-w, --watch` | Watch files and re-run tests on change | | `--log-junit, --report-junit ` | Write JUnit XML report | | `--log-gha ` | Write GitHub Actions workflow-commands log | -| `-j, --jobs ` | Run tests in parallel with max N concurrent jobs | +| `-j, --jobs ` | Run tests in parallel with max N concurrent jobs (`auto` = CPU cores) | | `-p, --parallel` | Run tests in parallel | | `--no-parallel` | Run tests sequentially | | `-r, --report-html ` | Write HTML report | @@ -314,22 +314,24 @@ run sequentially. ### Jobs -> `bashunit test -j|--jobs ` +> `bashunit test -j|--jobs ` Run tests in parallel with a maximum of N concurrent jobs. This implicitly enables parallel mode. Use this to limit CPU usage on CI or machines with constrained resources. +Pass `auto` to cap concurrency at the number of detected CPU cores. ::: code-group ```bash [Example] bashunit test tests/ --jobs 4 +bashunit test tests/ --jobs auto ``` ::: ::: tip `--jobs 0` (the default) means unlimited concurrency, which is equivalent to -`--parallel`. +`--parallel`. `--jobs auto` caps at the detected CPU core count. ::: ### Output Style diff --git a/src/check_os.sh b/src/check_os.sh index 91298cfa..46558e91 100644 --- a/src/check_os.sh +++ b/src/check_os.sh @@ -61,6 +61,30 @@ function bashunit::check_os::is_windows() { esac } +## +# Detects the number of online CPU cores, portably across Linux/macOS/BSD. +# Tries nproc, then sysctl, then getconf; falls back to 4 when none report a +# usable positive integer. Takes the first whitespace-delimited token so a +# stray flag or trailing text never poisons the arithmetic guard. +# Returns: prints the core count (>= 1) to stdout. +## +function bashunit::check_os::nproc() { + local cores="" + cores="$(nproc 2>/dev/null)" || cores="" + if [ -z "$cores" ]; then + cores="$(sysctl -n hw.ncpu 2>/dev/null)" || cores="" + fi + if [ -z "$cores" ]; then + cores="$(getconf _NPROCESSORS_ONLN 2>/dev/null)" || cores="" + fi + cores="${cores%% *}" + case "$cores" in + '' | *[!0-9]*) cores=4 ;; + esac + [ "$cores" -lt 1 ] && cores=4 + echo "$cores" +} + function bashunit::check_os::is_busybox() { case "$_BASHUNIT_DISTRO" in @@ -78,6 +102,7 @@ bashunit::check_os::init export _BASHUNIT_OS export _BASHUNIT_DISTRO +export -f bashunit::check_os::nproc export -f bashunit::check_os::is_alpine export -f bashunit::check_os::is_busybox export -f bashunit::check_os::is_ubuntu diff --git a/src/console_header.sh b/src/console_header.sh index b0655722..44c030cf 100644 --- a/src/console_header.sh +++ b/src/console_header.sh @@ -119,7 +119,7 @@ Options: --tag Only run tests with matching @tag (repeatable, OR logic) --exclude-tag Skip tests with matching @tag (repeatable, exclude wins) --log-junit, --report-junit Write JUnit XML report - -j, --jobs Run tests in parallel with max N concurrent jobs + -j, --jobs Run tests in parallel with max N concurrent jobs ("auto" = CPU cores) -p, --parallel Run tests in parallel (unlimited concurrency) --no-parallel Run tests sequentially -r, --report-html Write HTML report diff --git a/src/main.sh b/src/main.sh index e69b4ba1..6aabd7d7 100644 --- a/src/main.sh +++ b/src/main.sh @@ -99,7 +99,13 @@ function bashunit::main::cmd_test() { ;; -j | --jobs) export BASHUNIT_PARALLEL_RUN=true - export BASHUNIT_PARALLEL_JOBS="$2" + # "auto" caps at the detected core count; wait_for_job_slot needs an + # integer, so resolve it here rather than leaking the string downstream. + if [ "$2" = "auto" ]; then + export BASHUNIT_PARALLEL_JOBS="$(bashunit::check_os::nproc)" + else + export BASHUNIT_PARALLEL_JOBS="$2" + fi shift ;; --no-parallel) diff --git a/tests/acceptance/bashunit_parallel_consistency_test.sh b/tests/acceptance/bashunit_parallel_consistency_test.sh index 3fccd41a..a30f4208 100644 --- a/tests/acceptance/bashunit_parallel_consistency_test.sh +++ b/tests/acceptance/bashunit_parallel_consistency_test.sh @@ -23,3 +23,22 @@ function test_parallel_and_sequential_results_match() { assert_equals "$sequential_summary" "$parallel_summary" } + +function test_jobs_auto_caps_at_detected_cores_and_matches_sequential() { + local file1=tests/acceptance/fixtures/test_bashunit_when_a_test_passes.sh + local file2=tests/acceptance/fixtures/test_bashunit_when_a_test_fail.sh + + local sequential_output + sequential_output=$(./bashunit --no-parallel --env "$TEST_ENV_FILE" "$file1" "$file2" 2>&1) || true + + local auto_output + auto_output=$(./bashunit --jobs auto --env "$TEST_ENV_FILE" "$file1" "$file2" 2>&1) || true + + local sequential_summary + sequential_summary=$(echo "$sequential_output" | grep -e "Tests:" -e "Assertions:" | tr '\n' ' ') || true + + local auto_summary + auto_summary=$(echo "$auto_output" | grep -e "Tests:" -e "Assertions:" | tr '\n' ' ') || true + + assert_equals "$sequential_summary" "$auto_summary" +} diff --git a/tests/unit/check_os_test.sh b/tests/unit/check_os_test.sh index 167940c5..061217fb 100644 --- a/tests/unit/check_os_test.sh +++ b/tests/unit/check_os_test.sh @@ -77,3 +77,32 @@ function test_not_alpine_is_not_busybox() { assert_general_error "$(bashunit::check_os::is_alpine)" assert_general_error "$(bashunit::check_os::is_busybox)" } + +function test_nproc_uses_nproc_when_available() { + bashunit::mock nproc echo "8" + + assert_same "8" "$(bashunit::check_os::nproc)" +} + +function test_nproc_falls_back_to_sysctl_when_nproc_unavailable() { + bashunit::mock nproc mock_false + bashunit::mock sysctl echo "6" + + assert_same "6" "$(bashunit::check_os::nproc)" +} + +function test_nproc_defaults_to_four_when_no_detector_available() { + bashunit::mock nproc mock_false + bashunit::mock sysctl mock_false + bashunit::mock getconf mock_false + + assert_same "4" "$(bashunit::check_os::nproc)" +} + +function test_nproc_defaults_to_four_when_output_is_not_numeric() { + bashunit::mock nproc echo "not-a-number" + bashunit::mock sysctl mock_false + bashunit::mock getconf mock_false + + assert_same "4" "$(bashunit::check_os::nproc)" +}