Skip to content

fix(variables): keep dynamic well-known vars live across declare -a / array assign#1228

Open
lu-zero wants to merge 1 commit into
reubeno:mainfrom
lu-zero:pipestatus-dynamic-freeze
Open

fix(variables): keep dynamic well-known vars live across declare -a / array assign#1228
lu-zero wants to merge 1 commit into
reubeno:mainfrom
lu-zero:pipestatus-dynamic-freeze

Conversation

@lu-zero

@lu-zero lu-zero commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Problem

Once a script declare -as or plain-array-assigns a dynamic well-known variable such as PIPESTATUS, brush never updates it again on later pipelines in that shell — it stays frozen at whatever was last assigned. Real bash keeps the variable live and refreshes it on every pipeline.

Minimal repro:

declare -a PIPESTATUS=([0]="1")
true | true | true
echo "${PIPESTATUS[@]}"
  • bash: 0 0 0
  • brush (current origin/main): 1 (frozen)

Same freeze happens with a plain array assignment (no declare):

PIPESTATUS=([0]="9")
true | false | true
echo "${PIPESTATUS[@]}"
  • bash: 0 1 0
  • brush: 9

Root cause

PIPESTATUS (and other dynamic well-known variables: RANDOM, SECONDS, FUNCNAME, BASH_LINENO, BASH_SOURCE, BASH_ALIASES, …) are backed by a ShellValue::Dynamic { getter, setter } whose getter is consulted on every read and whose setter is a no-op (|_| ()) for all of them.

Two write paths in brush-core/src/variables.rs unconditionally overwrote self.value with a static array, discarding the Dynamic binding and permanently freezing the variable:

  1. convert_to_indexed_array — the _ arm hit for Dynamic values and replaced self.value. Triggered by declare -a VAR=(...) via brush-builtins/src/declare.rs.
  2. assign (non-append Array-literal arm) — explicitly matched ShellValue::Dynamic { .. } in the overwrite set. Triggered by a plain VAR=([0]="...") assignment.

From that point on the getter closure was gone, so the variable stopped refreshing for the rest of the shell's life.

Fix

  • convert_to_indexed_array: add an explicit ShellValue::Dynamic { .. } => Ok(()) arm so the binding survives.
  • assign: drop ShellValue::Dynamic { .. } from the overwrite set; the existing (ShellValue::Dynamic { .. }, _) => Ok(()) arm below now handles it correctly.

convert_to_associative_array intentionally unchanged

convert_to_associative_array has the same overwrite pattern, but real bash is asymmetric here: declare -A PIPESTATUS=([x]="9") does permanently freeze it in bash itself (PIPESTATUS becomes a plain associative array that pipelines don't refresh). Pre-fix brush already matched bash's -A behavior, so changing it would diverge from bash compat.

Verification

All four behavioral cases now match bash:

op bash brush (after)
declare -a then pipe 0 0 0 0 0 0
declare -A then pipe 9 9
plain =([0]="9") then pipe 0 1 0 0 1 0
plain pipe 0 0 0 0 0 0

Adds three compat test cases under brush-shell/tests/cases/compat/well_known_vars.yaml (declare -a, plain array assign, declare -A). cargo fmt --check clean.

This surfaced in the wild: a worker-env dump/restore mechanism restored a declare -a PIPESTATUS=([0]="1") line and a downstream pipestatus || die check fired incorrectly, misreporting a successful pipeline as failed.

@lu-zero

lu-zero commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

The Source code checks (1.88.0, ubuntu) failure is cargo deny --all-features check all flagging two newly-published advisories (RUSTSEC-2026-0194, RUSTSEC-2026-0195) in the advisory DB. bans, licenses, and sources all pass. This is unrelated to this change (a 2-line variables.rs edit + tests) and would fail on origin/main too — it is an upstream advisory-DB/infra issue affecting all open PRs.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown

Performance Benchmark Report

Benchmark name Baseline (μs) Test/PR (μs) Delta (μs) Delta %
clone_shell_object 17.40 μs 17.25 μs -0.15 μs ⚪ Unchanged
eval_arithmetic 0.15 μs 0.15 μs 0.00 μs ⚪ Unchanged
expand_one_string 1.80 μs 1.76 μs -0.04 μs ⚪ Unchanged
for_loop 30.59 μs 30.84 μs 0.25 μs ⚪ Unchanged
full_peg_complex 57.51 μs 58.00 μs 0.49 μs 🟠 +0.85%
full_peg_for_loop 6.20 μs 6.20 μs 0.00 μs ⚪ Unchanged
full_peg_nested_expansions 16.25 μs 16.06 μs -0.19 μs 🟢 -1.16%
full_peg_pipeline 4.25 μs 4.25 μs 0.00 μs ⚪ Unchanged
full_peg_simple 1.77 μs 1.78 μs 0.01 μs ⚪ Unchanged
function_call 3.82 μs 3.61 μs -0.21 μs ⚪ Unchanged
instantiate_shell 54.86 μs 54.37 μs -0.49 μs 🟢 -0.90%
instantiate_shell_with_init_scripts 27352.41 μs 26770.31 μs -582.11 μs ⚪ Unchanged
parse_peg_bash_completion 2108.11 μs 2112.12 μs 4.01 μs ⚪ Unchanged
parse_peg_complex 20.52 μs 20.68 μs 0.16 μs 🟠 +0.76%
parse_peg_for_loop 2.01 μs 2.03 μs 0.02 μs 🟠 +1.19%
parse_peg_pipeline 2.15 μs 2.13 μs -0.02 μs ⚪ Unchanged
parse_peg_simple 1.09 μs 1.09 μs -0.00 μs ⚪ Unchanged
run_echo_builtin_command 16.51 μs 16.16 μs -0.35 μs ⚪ Unchanged
tokenize_sample_script 3.37 μs 3.41 μs 0.04 μs ⚪ Unchanged

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
brush-core/src/variables.rs 🟢 91.38% 🟢 91.57% 🟢 0.19%
Overall Coverage 🟢 74.81% 🟢 74.81% ⚪ 0%

Minimum allowed coverage is 70%, this run produced 74.81%
Maximum allowed coverage difference is -5%, this run produced 0%

Test Summary: bash-completion test suite

Outcome Count Percentage
✅ Pass 1583 75.06
❗️ Error 17 0.81
❌ Fail 155 7.35
⏩ Skip 339 16.07
❎ Expected Fail 13 0.62
✔️ Unexpected Pass 2 0.09
📊 Total 2109 100.00

@lu-zero lu-zero force-pushed the pipestatus-dynamic-freeze branch from 5239ee8 to 2af5bea Compare July 5, 2026 13:16
… array assign

`PIPESTATUS` (and other dynamic well-known variables: RANDOM, SECONDS,
FUNCNAME, BASH_LINENO, BASH_SOURCE, BASH_ALIASES, ...) are backed by a
`ShellValue::Dynamic { getter, setter }` whose `getter` is consulted on
every read and whose `setter` is a no-op (`|_| ()`) for all of them.

Two write paths were unconditionally overwriting `self.value` with a
static array, discarding the Dynamic binding and permanently freezing
the variable at whatever was last assigned:

1. `convert_to_indexed_array` -- the `_` arm hit for Dynamic values.
   Triggered by `declare -a VAR=(...)`.
2. `assign` (non-append Array-literal arm) -- explicitly matched
   `ShellValue::Dynamic { .. }` in the overwrite set. Triggered by a
   plain `VAR=([0]="...")` assignment.

From that point on the getter closure was gone, so the variable stopped
refreshing for the rest of the shell's life. Minimal repro:

    declare -a PIPESTATUS=([0]="1")
    true | true | true
    echo "${PIPESTATUS[@]}"          # bash: 0 0 0 ; brush: 1 (frozen)

Fix:
- `convert_to_indexed_array`: add an explicit `ShellValue::Dynamic { .. }`
  => Ok(()) arm so the binding survives.
- `assign`: drop `ShellValue::Dynamic { .. }` from the overwrite set; the
  existing `(ShellValue::Dynamic { .. }, _) => Ok(())` arm below now
  handles it correctly.

`convert_to_associative_array` is intentionally left unchanged: real
bash itself permanently freezes PIPESTATUS into a plain associative
array after `declare -A`, and pre-fix brush already matched that
behavior. Changing it would diverge from bash compat.

Adds three compat test cases mirroring the repro (`declare -a`, plain
array assign, and `declare -A`) under well_known_vars.yaml.
@lu-zero lu-zero force-pushed the pipestatus-dynamic-freeze branch from 2af5bea to 5add53b Compare July 5, 2026 16:38
@reubeno reubeno requested a review from Copilot July 11, 2026 19:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes PIPESTATUS becoming static after indexed-array declarations or assignments.

Changes:

  • Preserves dynamic values during indexed-array conversion and assignment.
  • Adds compatibility tests for indexed, associative, and plain assignments.
  • Review found the conversion is incorrectly applied to all dynamic variable types.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
brush-core/src/variables.rs Adjusts dynamic variable conversion and assignment.
brush-shell/tests/cases/compat/well_known_vars.yaml Adds PIPESTATUS regression tests.

// freeze the variable at whatever snapshot we materialized, breaking it
// forever. Real bash keeps these variables live across `declare -a`, so
// accept the declaration syntactically but leave the value untouched.
ShellValue::Dynamic { .. } => Ok(()),
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants