From 3bbda945cef50f5c9b5ead93947177b47aaf4f37 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 7 Sep 2026 16:27:57 +0200 Subject: [PATCH 1/2] fix(runner): job-control noise and timeout grace Two defects a --parallel full-suite run surfaced. A worker turns on job control so each test is its own process group. Bash's parent-side setpgid loses a harmless race against a child that already exec'd, macOS answers EPERM, and bash reports every errno but ESRCH. The line was replayed under "Stderr from " for a file that wrote nothing. The child sets the group itself before it execs, so the group was always right and only the message was wrong. The timeout watchdog gave the body a flat 0.3s between its SIGTERM and its SIGKILL. That window has to cover killing whatever the body blocked on, waking bash, reaching its EXIT trap and running tear_down, which a loaded machine did not manage: the hook was skipped in about one parallel full-suite run in three. The grace now polls for the body to exit, bounded at 2s, so the common case ends sooner than the old sleep and a slow hook still completes. --- CHANGELOG.md | 2 ++ src/console/test_line.sh | 12 +++++++++++- src/runner/exec.sh | 13 +++++++++++- tests/acceptance/bashunit_timeout_test.sh | 23 ++++++++++++++++++++++ tests/unit/console/results_test.sh | 24 +++++++++++++++++++++++ 5 files changed, 72 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0a5ad0d..9ccf32ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed - Coverage propagates execution counts across multiline array literals, quoted strings, heredocs and backslash continuations, including when Bash 3.x records an array assignment on its closing `)`. Parent statement hits no longer mark commands inside command or process substitutions as covered, including within quotes or arrays (#1338) - `--exclude-filter` treats a comma in one flag value as part of the test function name. Repeat the flag to exclude several names; the comma-separated `BASHUNIT_EXCLUDE_FILTER` cannot express a literal comma within one configured filter (#1340) +- A `--parallel` run no longer replays bash's own job-control diagnostic as the stderr of a file that wrote nothing. Each test runs in its own process group, and bash's parent-side `setpgid` loses a harmless race against a child that already exec'd, which macOS answers with EPERM. The child sets the group itself before it execs, so the group was always right and only the message was wrong (#1344) +- A timed-out test finishes its `tear_down` instead of racing a flat 0.3s window. The watchdog's SIGTERM has to kill whatever the body blocked on, wake bash, reach its EXIT trap and run the hook, which a loaded machine did not manage before the SIGKILL landed: the hook was skipped in about one parallel full-suite run in three. The grace now polls for the body to exit, bounded at 2s, so the common case ends sooner than the old sleep and a slow hook still completes (#1344) ## [0.50.1](https://github.com/TypedDevs/bashunit/compare/0.50.0...0.50.1) - 2026-08-22 diff --git a/src/console/test_line.sh b/src/console/test_line.sh index bc947de5..5b06202f 100644 --- a/src/console/test_line.sh +++ b/src/console/test_line.sh @@ -283,11 +283,21 @@ function bashunit::console_results::print_worker_stderr() { local test_file="$1" local stderr_file="$2" + # Not the file's output: the worker turns on job control so each test is its + # own process group, and bash's parent-side setpgid loses a harmless race + # against a child that already exec'd. The child set the group itself before + # exec, so the group is right and only the diagnostic is wrong -- but bash + # reports every errno except ESRCH, and macOS answers EPERM. + local noise='child setpgid (' + + # Nothing but noise means no block at all, so a clean run stays quiet. + grep -qv "$noise" "$stderr_file" || return 0 + # To stderr, which is where this text came from: on stdout it landed ahead of # the document `--output json|junit` promises that stream is, so a worker that # wrote anything to stderr -- a failing `set_up`, for one -- made the report # unparseable. printf "\n%sStderr from %s%s\n" \ "$_BASHUNIT_COLOR_SKIPPED" "$test_file" "$_BASHUNIT_COLOR_DEFAULT" >&2 - sed 's/^/|/' "$stderr_file" >&2 + grep -v "$noise" "$stderr_file" | sed 's/^/|/' >&2 } diff --git a/src/runner/exec.sh b/src/runner/exec.sh index 8c166fa0..72c313a1 100644 --- a/src/runner/exec.sh +++ b/src/runner/exec.sh @@ -421,7 +421,18 @@ function bashunit::runner::run_with_timeout() { kill -0 "$test_pid" 2>/dev/null || exit 0 : >"$marker_file" kill -TERM -"$test_pid" 2>/dev/null - sleep 0.3 + # Poll for the body to go instead of sleeping a flat window. The TERM has to + # kill whatever the body blocked on, wake bash, reach the EXIT trap and run + # tear_down; a flat 0.3s was a scheduling hiccup, not a grace, and on a + # loaded machine the body had not reached tear_down when the SIGKILL landed. + # Bounded at 2s so a hook that hangs cannot outlive the run, and the common + # case leaves as soon as the body is reaped -- sooner than the old sleep. + grace_ticks=0 + while [ "$grace_ticks" -lt 20 ]; do + kill -0 "$test_pid" 2>/dev/null || break + sleep 0.1 + grace_ticks=$((grace_ticks + 1)) + done kill -KILL -"$test_pid" 2>/dev/null ) /dev/null 2>&1 & local watchdog_pid=$! diff --git a/tests/acceptance/bashunit_timeout_test.sh b/tests/acceptance/bashunit_timeout_test.sh index cf074470..7b12676c 100644 --- a/tests/acceptance/bashunit_timeout_test.sh +++ b/tests/acceptance/bashunit_timeout_test.sh @@ -119,3 +119,26 @@ function test_bashunit_runs_tear_down_for_a_timed_out_test() { assert_file_exists "$marker.setup" assert_file_exists "$marker.teardown" } + +# The watchdog gave the body a flat 0.3s between its SIGTERM and its SIGKILL, +# which is a scheduling hiccup, not a grace: on a loaded machine the body had +# not even reached tear_down yet, and the #1324 test above failed about one +# parallel full-suite run in three. A tear_down slower than that flat window is +# the deterministic form of the same miss. +function test_bashunit_waits_for_a_slow_tear_down_of_a_timed_out_test() { + local dir fixture marker + dir="$(bashunit::temp_dir timeout_slow_teardown)" + fixture="$dir/hanging_test.sh" + marker="$dir/marker" + { + printf 'function tear_down() { sleep 1; : >"$TIMEOUT_MARKER.teardown"; }\n' + printf 'function test_hangs() { sleep 30; assert_true true; }\n' + } >"$fixture" + + local output + output="$(TIMEOUT_MARKER="$marker" ./bashunit --no-parallel --env "$TEST_ENV_FILE" \ + --test-timeout 1 "$fixture")" || true + + assert_contains "Test timed out after 1s" "$output" + assert_file_exists "$marker.teardown" +} diff --git a/tests/unit/console/results_test.sh b/tests/unit/console/results_test.sh index 589777c0..f04e51ff 100644 --- a/tests/unit/console/results_test.sh +++ b/tests/unit/console/results_test.sh @@ -885,3 +885,27 @@ function test_tap_line_unknown_type_is_not_ok() { assert_same "not ok 9 - Something odd" "$out" } + +function test_worker_stderr_drops_bash_job_control_noise() { + local stderr_file out + stderr_file=$(bashunit::temp_file worker_stderr) + printf '%s\n' \ + './src/runner/exec.sh: child setpgid (68809 to 68809): Operation not permitted' \ + 'diagnostic written by the file under test' >"$stderr_file" + + out=$(bashunit::console_results::print_worker_stderr "some_test.sh" "$stderr_file" 2>&1) + + assert_contains "diagnostic written by the file under test" "$out" + assert_not_contains "child setpgid" "$out" +} + +function test_worker_stderr_block_is_omitted_when_only_job_control_noise() { + local stderr_file out + stderr_file=$(bashunit::temp_file worker_stderr) + printf '%s\n' \ + './src/runner/exec.sh: child setpgid (1 to 1): Operation not permitted' >"$stderr_file" + + out=$(bashunit::console_results::print_worker_stderr "some_test.sh" "$stderr_file" 2>&1) + + assert_empty "$out" +} From 062b3abc6a79b60a9c2bafb299f1d62bb764b61c Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 7 Sep 2026 16:31:40 +0200 Subject: [PATCH 2/2] docs(changelog): tighten the unreleased entries --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccf32ac..8f8f940c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,10 @@ ## Unreleased ### Fixed -- Coverage propagates execution counts across multiline array literals, quoted strings, heredocs and backslash continuations, including when Bash 3.x records an array assignment on its closing `)`. Parent statement hits no longer mark commands inside command or process substitutions as covered, including within quotes or arrays (#1338) -- `--exclude-filter` treats a comma in one flag value as part of the test function name. Repeat the flag to exclude several names; the comma-separated `BASHUNIT_EXCLUDE_FILTER` cannot express a literal comma within one configured filter (#1340) -- A `--parallel` run no longer replays bash's own job-control diagnostic as the stderr of a file that wrote nothing. Each test runs in its own process group, and bash's parent-side `setpgid` loses a harmless race against a child that already exec'd, which macOS answers with EPERM. The child sets the group itself before it execs, so the group was always right and only the message was wrong (#1344) -- A timed-out test finishes its `tear_down` instead of racing a flat 0.3s window. The watchdog's SIGTERM has to kill whatever the body blocked on, wake bash, reach its EXIT trap and run the hook, which a loaded machine did not manage before the SIGKILL landed: the hook was skipped in about one parallel full-suite run in three. The grace now polls for the body to exit, bounded at 2s, so the common case ends sooner than the old sleep and a slow hook still completes (#1344) +- Coverage counts every line of a multiline statement: array literals, quoted strings, heredocs and backslash continuations, including the array assignment Bash 3.x records on its closing `)`. A parent statement's hits no longer mark commands inside command or process substitutions as covered, even within quotes or arrays (#1338) +- `--exclude-filter` keeps a comma as part of the test function name. Repeat the flag to exclude several names. `BASHUNIT_EXCLUDE_FILTER` stays comma-separated, so one of its filters cannot hold a literal comma (#1340) +- A `--parallel` run no longer replays bash's `child setpgid` warning as the stderr of a file that wrote nothing. Job control puts each test in its own process group, and bash's parent-side `setpgid` loses a harmless race with a child that already exec'd. The child sets the group first, so only the message was ever wrong (#1344) +- A timed-out test finishes its `tear_down`. The watchdog allowed a flat 0.3s between SIGTERM and SIGKILL, which a loaded machine missed in about one parallel full-suite run in three. It now polls for the body to exit, capped at 2s (#1344) ## [0.50.1](https://github.com/TypedDevs/bashunit/compare/0.50.0...0.50.1) - 2026-08-22