Skip to content

perf(coverage): the string-scan caches cost 26ms per lookup at 121 files #1056

Closed

Description

@Chemaclass

Problem

Three coverage caches store their entries in one long string and look entries up with a leading-* glob. On Bash 3.2 that scan is quadratic in the number of entries, and at real file counts the cache costs more than the work it saves.

  • _BASHUNIT_COVERAGE_STATS_LOOKUP (src/coverage/stats.sh:104 get_cached_stats), report path
  • _BASHUNIT_COVERAGE_TRACK_CACHE (src/coverage/engine.sh:226), capture hot path, scanned per traced line
  • _BASHUNIT_COVERAGE_PATH_CACHE (src/coverage/engine.sh:242), capture hot path, scanned per traced line

Measured on Bash 3.2 arm64, macOS. report_text uses the stats cache and does nothing else expensive, yet its per-file cost grows faster than the file count:

tracked files report_text per file
11 0.03s 3ms
40 0.16s 4ms
80 0.85s 11ms
121 3.12s 26ms

Isolated micro-benchmark of the lookup alone, 121 entries, 8,254-byte lookup string, 605 lookups: 26.0 ms per lookup. 121 x 26ms = 3.1s, which is the whole measured report_text time. The cache is the cost.

The two capture-path caches use the same shape and are scanned once per traced line, so the same curve applies to every executed line of every tracked file.

Proposal

Replace string-scan lookup with Bash's own variable table, the associative-array emulation this repo already uses for spy state (_BASHUNIT_SPY_${variable}_TIMES_FILE, src/doubles/spy.sh):

# key -> a variable name in the reserved namespace
_bu_key="_BASHUNIT_COVERAGE_TRACK_${file//[^a-zA-Z0-9]/_}"
eval "$_bu_key=1"
decision="${!_bu_key:-}"

${!name} is Bash 2+, ${var//pattern/} is Bash 3.0+, and the paths being sanitized are short, so the substitution stays off the quadratic path that perf-fork-budget.md warns about for large strings.

For the stats cache there is a simpler fix that removes the lookup entirely: precompute_file_stats already walks get_tracked_files in order and fills the _BASHUNIT_COVERAGE_STATS_* indexed arrays. report_text should iterate those arrays instead of re-reading the tracked list and looking each file up by name.

Where to change

  • src/coverage/stats.sh:66-116 the stats cache and get_cached_stats
  • src/coverage/report_text.sh:39-62 iterate the precomputed arrays
  • src/coverage/paths.sh:5-7 cache declarations
  • src/coverage/engine.sh:215-273 record_line, both hot-path caches
  • src/coverage/config.sh:90-103 cache reset in init

Acceptance criteria

  • Coverage totals, per-file rows and LCOV output are byte-identical to the current implementation for the same run
  • report_text cost per file stops growing with file count (measure at 11, 40, 80, 121 tracked files)
  • A file whose sanitized key collides with another file's key is handled, or collisions are proven impossible for the sanitizer chosen
  • Cache state is reset by bashunit::coverage::init so a second run in the same shell cannot inherit stale decisions
  • Works under --parallel, where each worker builds its own caches
  • Both engines produce the same numbers (BASHUNIT_COVERAGE_ENGINE=trap and xtrace)

Repo checklist (agent)

  • TDD: RED then GREEN then REFACTOR. Write the failing test first.
  • Bash 3.0+ only: no printf -v, no += append, no declare -A, no [[ ]], no ${var,,}, no &>>, no ${arr[-1]}. Expanding a possibly-empty array under set -u needs ${arr[@]+"${arr[@]}"}.
  • Dynamic scope: everything written through eval must stay in the _BASHUNIT_* namespace, see .claude/rules/bash-style.md.
  • Gates: make sa, make lint, ./bashunit tests/, ./bashunit --parallel tests/. Never run shfmt -w.
  • CHANGELOG.md: one line under ## Unreleased (performance entry with the measured factor).
  • One issue = one PR.

Activity

  1. self-assigned this
    on Aug 12, 2026
  2. Chemaclass commented on Aug 12, 2026

    @Chemaclass
    MemberAuthor

    Done in #1076 (merged).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions