From 97792639ba726c086b554ac159fbfd3e1422f9d0 Mon Sep 17 00:00:00 2001 From: Krisztian Gajdar Date: Mon, 14 Sep 2026 19:40:14 +0200 Subject: [PATCH] fix(tasks): stop expanding possibly-empty arrays bare under set -u macOS bash 3.2.57 treats "${ARR[@]}" on an empty array as an unbound variable under set -u, so serve.bash failed before the server started for any non-cuda13 bundle. Use the portable ${ARR[@]+"${ARR[@]}"} form in serve.bash and test.bash, and add a test that scans every task script for the class. --- .../tests/test_bash_empty_array_expansion.py | 100 ++++++++++++++++++ tools/mise_tasks/serve.bash | 6 +- tools/mise_tasks/test.bash | 4 +- 3 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 tools/ci/tests/test_bash_empty_array_expansion.py diff --git a/tools/ci/tests/test_bash_empty_array_expansion.py b/tools/ci/tests/test_bash_empty_array_expansion.py new file mode 100644 index 000000000..ecd374612 --- /dev/null +++ b/tools/ci/tests/test_bash_empty_array_expansion.py @@ -0,0 +1,100 @@ +"""An array that can be empty must not be expanded bare under ``set -u``. + +macOS ships bash 3.2.57, where ``"${ARR[@]}"`` on an empty array is a hard +error under ``set -u`` rather than an empty expansion. A task script that +declares an array empty and fills it only on some paths, or seeds it from its +own arguments, then dies with ``ARR[@]: unbound variable`` on a stock macOS +shell before it does any work. + +The portable form is ``${ARR[@]+"${ARR[@]}"}``, which expands to nothing when +the array is empty and is identical otherwise. ``"${ARR[@]:-}"`` is not a +substitute: it injects one empty argument. + +This guards the class rather than the scripts that had it. Any array a task +script declares as possibly empty, either literally or from the script's own +arguments, must use the guarded form wherever it is expanded. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +TASKS = Path(__file__).resolve().parents[2] / "mise_tasks" +# An array that starts empty, or that starts as this script's arguments and so +# is empty whenever the task is run without any. +_POSSIBLY_EMPTY = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)=\(\s*(?:"\$@"\s*)?\)\s*$', re.MULTILINE) +_STRICT = ("set -u", "set -eu", "set -euo") + + +def _bare_expansions(source: str, name: str) -> list[int]: + """Line numbers where ``name`` is expanded without the empty-array guard. + + Quoting is irrelevant to the crash. bash 3.2 refuses an empty array at + ``${ARR[@]}`` just as it does at ``"${ARR[@]}"``, so the scan must not + require the quotes. + + The guarded form contains the bare expansion inside itself, at a fixed + offset, so a hit is excused only when the guard starts at exactly the + position that would make this hit its inner expansion. Searching a window + instead would excuse an unsafe expansion that merely sits beside a guarded + one, as in ``${ARR[@]+"${ARR[@]}"}${ARR[@]}``, which bash still evaluates. + """ + bare = f"${{{name}[@]}}" + guarded = f'${{{name}[@]+"{bare}"}}' + inner_offset = guarded.index(bare, 1) + lines: list[int] = [] + for match in re.finditer(re.escape(bare), source): + if source.startswith(guarded, match.start() - inner_offset): + continue + lines.append(source[: match.start()].count("\n") + 1) + return lines + + +def test_no_task_script_expands_a_possibly_empty_array_bare() -> None: + offenders: list[str] = [] + declared = 0 + scanned = 0 + for script in sorted(TASKS.glob("*.bash")): + source = script.read_text(encoding="utf-8") + if not any(flag in source for flag in _STRICT): + continue + scanned += 1 + for name in sorted(set(_POSSIBLY_EMPTY.findall(source))): + declared += 1 + for line in _bare_expansions(source, name): + offenders.append(f"{script.name}:{line} expands {name}[@] bare under set -u") + + assert offenders == [], offenders + # Positive controls: a scan that matched no scripts, or no arrays inside + # them, would pass while proving nothing. + assert scanned >= 15, scanned + assert declared >= 3, declared + + +def test_the_guard_recognises_the_crashing_form() -> None: + """The detector must fire on the real shape and stay quiet on the fix.""" + broke = 'set -euo pipefail\nUV_BUNDLE_ARGS=()\nuv run "${UV_BUNDLE_ARGS[@]}" serve\n' + fixed = 'set -euo pipefail\nUV_BUNDLE_ARGS=()\nuv run ${UV_BUNDLE_ARGS[@]+"${UV_BUNDLE_ARGS[@]}"} serve\n' + + assert _POSSIBLY_EMPTY.findall(broke) == ["UV_BUNDLE_ARGS"] + assert _bare_expansions(broke, "UV_BUNDLE_ARGS") == [3] + assert _bare_expansions(fixed, "UV_BUNDLE_ARGS") == [] + + # Quoting is irrelevant to the crash: bash 3.2 refuses the unquoted form + # too, so a detector that required the quotes would pass it silently. + unquoted = "set -euo pipefail\nUV_BUNDLE_ARGS=()\nuv run ${UV_BUNDLE_ARGS[@]} serve\n" + assert _bare_expansions(unquoted, "UV_BUNDLE_ARGS") == [3] + + # An unsafe expansion beside a guarded one is still unsafe: bash evaluates + # the second, and a window search would have excused it. + adjacent = 'set -euo pipefail\nA=()\nrun ${A[@]+"${A[@]}"}${A[@]}\n' + assert _bare_expansions(adjacent, "A") == [3] + + # The guarded form alone stays quiet, so the exclusion still works. + assert _bare_expansions('set -euo pipefail\nA=()\nrun ${A[@]+"${A[@]}"}\n', "A") == [] + + # An array seeded from the task's own arguments is empty whenever the task + # is run with none, which is how serve.bash reaches the same crash. + from_argv = 'set -euo pipefail\nSERVER_ARGS=("$@")\nserve "${SERVER_ARGS[@]}"\n' + assert _POSSIBLY_EMPTY.findall(from_argv) == ["SERVER_ARGS"] diff --git a/tools/mise_tasks/serve.bash b/tools/mise_tasks/serve.bash index ef96ee35b..08299c06c 100755 --- a/tools/mise_tasks/serve.bash +++ b/tools/mise_tasks/serve.bash @@ -106,8 +106,8 @@ fi if [[ -s "$REQS_FILE" ]]; then echo "Syncing adapter dependencies..." mise exec -- uv run --frozen --project . --package sie-server \ - "${UV_BUNDLE_ARGS[@]}" \ - --with-requirements "$REQS_FILE" python -m sie_server.cli serve "${SERVER_ARGS[@]}" + ${UV_BUNDLE_ARGS[@]+"${UV_BUNDLE_ARGS[@]}"} \ + --with-requirements "$REQS_FILE" python -m sie_server.cli serve ${SERVER_ARGS[@]+"${SERVER_ARGS[@]}"} else - mise exec -- uv run --frozen --project . --package sie-server python -m sie_server.cli serve "${SERVER_ARGS[@]}" + mise exec -- uv run --frozen --project . --package sie-server python -m sie_server.cli serve ${SERVER_ARGS[@]+"${SERVER_ARGS[@]}"} fi diff --git a/tools/mise_tasks/test.bash b/tools/mise_tasks/test.bash index 567f434b6..ad669e105 100755 --- a/tools/mise_tasks/test.bash +++ b/tools/mise_tasks/test.bash @@ -68,7 +68,7 @@ if [[ -n "${usage_model:-}" ]]; then while IFS= read -r node_id; do model_node_ids+=("${node_id}") done <<<"${selected_tests}" - ARGS+=("${model_node_ids[@]}") + ARGS+=(${model_node_ids[@]+"${model_node_ids[@]}"}) fi fi @@ -76,5 +76,5 @@ echo "## Running public workspace tests" if [[ ${#ARGS[@]} -eq 0 ]]; then mise exec -- uv run --frozen --project . --no-sync pytest -c pyproject.toml else - mise exec -- uv run --frozen --project . --no-sync pytest -c pyproject.toml "${ARGS[@]}" + mise exec -- uv run --frozen --project . --no-sync pytest -c pyproject.toml ${ARGS[@]+"${ARGS[@]}"} fi