Part of #761.
Problem
bashunit::runner::run_test (src/runner.sh:1036) and its helpers fork ~12-14 pure-bash $(...) subshells plus several avoidable external commands per test. Individually cheap (~0.33ms per subshell on macOS bash 3.2), together ~5-7ms per test, ~7s across the suite, compounding in nested acceptance runs.
The repo already has the documented fix pattern: the dedicated-global-return-slot ("outvar") convention in .claude/rules/bash-style.md, already used by _BASHUNIT_RUNNER_FIELD_OUT and friends in src/runner.sh.
Task — checklist, one commit each
Danger zone
Bash local is dynamically scoped: a helper's local can shadow the caller's variable when returning via eval/${!name}. This exact bug caused 12 parallel failures in PR #672. Follow .claude/rules/bash-style.md (dedicated _BASHUNIT_* global slots, natural local names) and add a regression test whenever a helper takes an outvar name.
Tests first (TDD)
Behavior must not change; the existing suite is the spec. For each conversion, run the affected unit/acceptance files before and after. Add regression tests only where a new return-slot helper is introduced (call it with one of its own internal variable names, per the style rule).
Constraints
- Bash 3.0+ only: no
declare -A, no [[ ]], no ${var,,}, no negative array indexing, no &>>; printf -v is 3.1+ — avoid it. - Console output must remain byte-identical (snapshot/acceptance tests will catch drift).
Acceptance criteria
Line references valid at commit 4d80e7c.
Part of #761.
Problem
bashunit::runner::run_test(src/runner.sh:1036) and its helpers fork ~12-14 pure-bash$(...)subshells plus several avoidable external commands per test. Individually cheap (~0.33ms per subshell on macOS bash 3.2), together ~5-7ms per test, ~7s across the suite, compounding in nested acceptance runs.The repo already has the documented fix pattern: the dedicated-global-return-slot ("outvar") convention in
.claude/rules/bash-style.md, already used by_BASHUNIT_RUNNER_FIELD_OUTand friends insrc/runner.sh.Task — checklist, one commit each
bashunit::runner::detect_runtime_erroris called twice per test with the same input (src/runner.sh:1081andsrc/runner.sh:1125): compute once, reuse; convert to a return slot.retry_max=$(bashunit::env::retry_count)(src/runner.sh:1063) forks per test for a value constant across the whole run: resolve once at startup (env layer) and read a global.subshell_output=$(bashunit::runner::decode_subshell_output ...)(src/runner.sh:1110): return-slot conversion (the innerbase64 -dfork is covered by perf(state): avoid base64 forks for empty title and hook message in export_subshell_context #762; this item is just the wrapper subshell).bashunit::helper::normalize_test_function_name(src/runner.sh:1152->src/helpers.sh:40) nests$(bashunit::helper::get_test_title ...)(src/helpers.sh:45): flatten to return slots.bashunit::console_results::print_successful_testbuilds its output line via$(printf ...)(src/console_results.sh:232,243): build withprintf -v-free pure-bash concatenation or a return slot (printf -vis fine per se but has the dynamic-scope caveat documented in the style rule — prefer the slot).bashunit::cleanup_testcase_temp_files(src/globals.sh:67-72) runsrm -rf "$BASHUNIT_TEMP_DIR/${ID}_*"for every test even when the test never calledbashunit::temp_file/temp_dir: set a flag (or counter) inbashunit::temp_file/bashunit::temp_dir(src/globals.sh:38-65) and skip thermfork when nothing was created. Mind that the flag must live in the test subshell where the temp file was created and the cleanup runs (cleanup_on_exit,src/runner.sh:1300) — verify they share the same process.bashunit::runner::generate_id(src/runner.sh:53->src/helpers.sh:454) andresolve_test_location(src/runner.sh:77): return-slot conversions if straightforward; skip if they require behavior changes.bashunit::helper::get_functions_to_run(src/helpers.sh:203-225) iterates the fulldeclare -Flist (~970 functions once the framework is loaded) per file. Cheap per file, but if the input can be pre-filtered to the script's own functions (_BASHUNIT_CACHED_ALL_FUNCTIONS,src/runner.sh:392), do it; otherwise note why not in the PR.Danger zone
Bash
localis dynamically scoped: a helper'slocalcan shadow the caller's variable when returning viaeval/${!name}. This exact bug caused 12 parallel failures in PR #672. Follow.claude/rules/bash-style.md(dedicated_BASHUNIT_*global slots, natural local names) and add a regression test whenever a helper takes an outvar name.Tests first (TDD)
Behavior must not change; the existing suite is the spec. For each conversion, run the affected unit/acceptance files before and after. Add regression tests only where a new return-slot helper is introduced (call it with one of its own internal variable names, per the style rule).
Constraints
declare -A, no[[ ]], no${var,,}, no negative array indexing, no&>>;printf -vis 3.1+ — avoid it.Acceptance criteria
./bashunit tests/and./bashunit --parallel tests/pass after EVERY commit.make sa,make lintpass;shfmt -w .produces no diff.Line references valid at commit 4d80e7c.