fix(variables): keep dynamic well-known vars live across declare -a / array assign#1228
Open
lu-zero wants to merge 4 commits into
Open
fix(variables): keep dynamic well-known vars live across declare -a / array assign#1228lu-zero wants to merge 4 commits into
declare -a / array assign#1228lu-zero wants to merge 4 commits into
Conversation
Contributor
Author
|
The Source code checks (1.88.0, ubuntu) failure is |
Performance Benchmark Report
Code Coverage Report: Only Changed Files listed
Minimum allowed coverage is Test Summary: bash-completion test suite
|
lu-zero
force-pushed
the
pipestatus-dynamic-freeze
branch
2 times, most recently
from
July 5, 2026 16:38
2af5bea to
5add53b
Compare
Contributor
There was a problem hiding this comment.
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(()), |
… 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.
…version
Copilot correctly flagged that the earlier blanket
`ShellValue::Dynamic { .. } => Ok(())` in convert_to_indexed_array applied to
every dynamic variable, not just indexed-array-shaped ones like PIPESTATUS.
Verified against real bash: declare -a/-A on a dynamic variable should behave
according to its native shape, not uniformly:
- indexed-shaped (PIPESTATUS): declare -a keeps it live (already correct);
declare -A errors, matching a real indexed array.
- associative-shaped (BASH_ALIASES, BASH_CMDS): declare -A keeps it live;
declare -a errors, matching a real associative array.
- scalar-shaped (RANDOM, SECONDS, ...): both declare -a and declare -A
materialize (and permanently freeze) a real array, matching bash's observed
behavior for these.
Added a `DynamicValueKind` tag to `ShellValue::Dynamic` so the conversion
logic can make this decision without needing a shell reference to invoke the
getter. Also discovered (by testing against real bash) that an accompanying
value bypasses the shape check for dynamic variables specifically
(`declare -A PIPESTATUS=(...)` succeeds and freezes, while a bare
`declare -A PIPESTATUS` errors) — added `_for_reassignment` variants used
only when declare supplies an explicit value, to preserve this without
regressing the existing PIPESTATUS compat tests.
${BASH_ALIASES[x]} was silently returning empty instead of the alias value.
The check that decides whether an array-index expansion should treat its
index as a literal key (associative) vs. an arithmetic expression (indexed)
only recognized real ShellValue::AssociativeArray, not a Dynamic variable
that resolves to one. So "x" was evaluated arithmetically (as 0, via the
unrelated variable x) instead of used as the alias name, and the lookup
missed.
Now that Dynamic carries a DynamicValueKind, extend the check to also treat
DynamicValueKind::AssociativeArray as an associative array for this purpose.
Verified against real bash that ${BASH_ALIASES[x]} now resolves correctly.
…s it When declare -a/-A materializes a scalar-shaped dynamic variable (e.g. RANDOM, SECONDS) into a real array, it previously always froze it to an empty string, because the conversion path had no way to invoke the dynamic getter (which needs a shell reference). Thread a resolved snapshot through: at the one caller that has shell access (declare.rs), resolve the variable's current dynamic value via resolve_value() before taking the mutable borrow needed to perform the conversion, then pass it in as an optional pre-resolved value. Internal callers within variables.rs (String-only promotions) pass None, since they never operate on Dynamic values. Verified: `declare -a RANDOM; declare -p RANDOM` now freezes at RANDOM's actual current reading (e.g. `declare -ai RANDOM=([0]="1364")`) instead of an empty array, matching real bash.
lu-zero
force-pushed
the
pipestatus-dynamic-freeze
branch
from
July 16, 2026 06:53
5add53b to
ac587fc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Once a script
declare -as or plain-array-assigns a dynamic well-known variable such asPIPESTATUS, 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:
0 0 0origin/main):1(frozen)Same freeze happens with a plain array assignment (no
declare):0 1 09Root cause
PIPESTATUS(and other dynamic well-known variables:RANDOM,SECONDS,FUNCNAME,BASH_LINENO,BASH_SOURCE,BASH_ALIASES, …) are backed by aShellValue::Dynamic { getter, setter }whosegetteris consulted on every read and whosesetteris a no-op (|_| ()) for all of them.Two write paths in
brush-core/src/variables.rsunconditionally overwroteself.valuewith a static array, discarding theDynamicbinding and permanently freezing the variable:convert_to_indexed_array— the_arm hit forDynamicvalues and replacedself.value. Triggered bydeclare -a VAR=(...)viabrush-builtins/src/declare.rs.assign(non-append Array-literal arm) — explicitly matchedShellValue::Dynamic { .. }in the overwrite set. Triggered by a plainVAR=([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 explicitShellValue::Dynamic { .. } => Ok(())arm so the binding survives.assign: dropShellValue::Dynamic { .. }from the overwrite set; the existing(ShellValue::Dynamic { .. }, _) => Ok(())arm below now handles it correctly.convert_to_associative_arrayintentionally unchangedconvert_to_associative_arrayhas 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-Abehavior, so changing it would diverge from bash compat.Verification
All four behavioral cases now match bash:
declare -athen pipe0 0 00 0 0✓declare -Athen pipe99✓=([0]="9")then pipe0 1 00 1 0✓0 0 00 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 --checkclean.This surfaced in the wild: a worker-env dump/restore mechanism restored a
declare -a PIPESTATUS=([0]="1")line and a downstreampipestatus || diecheck fired incorrectly, misreporting a successful pipeline as failed.