|
| 1 | ++++ |
| 2 | +title = 'Fish and pipestatus' |
| 3 | +date = 2024-07-19T17:00:00-04:00 |
| 4 | +categories = 'tutorial' |
| 5 | +draft = true |
| 6 | +tags = ['shell', 'Fish'] |
| 7 | ++++ |
| 8 | + |
| 9 | +# Fish and `$pipestatus` |
| 10 | + |
| 11 | +_Note: This morning I answered [a Reddit |
| 12 | +post](https://www.reddit.com/r/fishshell/comments/1efpruv/about_checking_pipestatus/) |
| 13 | +about `$pipestatus`, and after learning that there's not a lot of good information out |
| 14 | +there, I thought making a blog post might be helpful for other future Fish users wanting |
| 15 | +to know more about using Fish's `$pipestatus`._ |
| 16 | + |
| 17 | +## What's an exit status? |
| 18 | + |
| 19 | +In Fish, like other shells, you can tell whether a command failed via its return value, |
| 20 | +sometimes called an exit code or an exit status. An exit status is a number between 0 |
| 21 | +and 255 (well, really 0-254, with 255 signaling any out-of-range exit code above 254). |
| 22 | +Canonically, an exit status of 0 means success, while any non-zero value indicates an |
| 23 | +error. |
| 24 | + |
| 25 | +_Note: Don't make the mistake of thinking 0 means FALSE and 1 means TRUE - that's not |
| 26 | +how exit statuses work, and is in fact the exact opposite of what they mean in this |
| 27 | +case._ |
| 28 | + |
| 29 | +According to the [Linux Documentation |
| 30 | +Project](https://tldp.org/LDP/abs/html/exitcodes.html), certain exit codes are reserved |
| 31 | +to have certain meanings: |
| 32 | + |
| 33 | +| Exit Code | Meaning | |
| 34 | +| ---------------- | ------------------------------ | |
| 35 | +| 0 | Success | |
| 36 | +| 1 | Catchall for general errors | |
| 37 | +| 2 | Misuse of shell built-ins | |
| 38 | +| 126 | Command invoked cannot execute | |
| 39 | +| 127 | Command not found | |
| 40 | +| 128 | Invalid argument to exit | |
| 41 | +| 128+n | Fatal error signal "n" | |
| 42 | +| 130 | Script terminated by Control-C | |
| 43 | +| 255 | Exit status out of range | |
| 44 | + |
| 45 | +In Bash or Zsh, the special variable `$?` holds the exit status. In Fish, you can see |
| 46 | +the exit status from a command if you examine the contents of the special `$status` |
| 47 | +variable. |
| 48 | + |
| 49 | +Now, `$status`, like `$?` in POSIX shells, is very volatile. Every Fish command you run |
| 50 | +changes the value of `$status`. It is common to see scripts where the exit status is |
| 51 | +stored in a variable so that it does't get blown away by subsequent commands. So, in |
| 52 | +Fish, that might look something like this: |
| 53 | + |
| 54 | +```fish |
| 55 | +somecmd --arg1 foo bar |
| 56 | +set --local last_status $status # store off the status |
| 57 | +if test $last_status -ne 0 |
| 58 | + # Write to stderr and return the status |
| 59 | + echo >&2 "Your command errored with status: $last_status..." |
| 60 | + return $last_status |
| 61 | +end |
| 62 | +``` |
| 63 | + |
| 64 | +If you tried to use `$status` here without saving it in a variable, every new command |
| 65 | +you run in the script would have modified its value with their own exit status. So in |
| 66 | +this case, `somecmd`, `test` and `echo` are commands, and each will change `$status`. |
| 67 | + |
| 68 | +Do to its volatility, you need to use or store `$status` immediately after a command |
| 69 | +completes, otherwise it will change when the next command runs and you will lose its |
| 70 | +prior value. |
| 71 | + |
| 72 | +## Piping commands |
| 73 | + |
| 74 | +When shell scripting, it is common to pipe commands. What we mean by piping commands is |
| 75 | +running a command and piping its output as the input to another command, and so on. |
| 76 | +Let's take a quick example in Fish where we print the URL to this blog post and pipe |
| 77 | +that to `string match` to pull out the slug name: |
| 78 | + |
| 79 | +```fish |
| 80 | +> echo "https://mattmc3.github.io/posts/2024/07/fish-and-pipestatus/" | |
| 81 | +> string match -rg '/([^/]+)/$' |
| 82 | +fish-and-pipestatus |
| 83 | +``` |
| 84 | + |
| 85 | +Here we just piped 2 commands, but you could pipe many more: |
| 86 | + |
| 87 | +```fish |
| 88 | +# How many blog posts did I write this year? |
| 89 | +ls | grep '2024' | wc -l | string trim |
| 90 | +``` |
| 91 | + |
| 92 | +Remember how we just said `$status` is volatile? Well, now we have 4 commands we just |
| 93 | +ran together, and each would change the value of `$status` as it went along, leaving |
| 94 | +`$status` to only reflect the result of running `string trim`. |
| 95 | + |
| 96 | +Enter `$pipestatus`. |
| 97 | + |
| 98 | +## Getting the exit status from piped commands |
| 99 | + |
| 100 | +Bash and Zsh have a way of handling command piping - setting the `$PIPESTATUS` or |
| 101 | +`$pipestatus` variables respectively. These variables are arrays which contain the exit |
| 102 | +status of each individual command. Let's demonstrate how that works in Bash: |
| 103 | + |
| 104 | +```bash |
| 105 | +$ true | false | true | true # Fake 4 piped commands |
| 106 | +$ echo ${PIPESTATUS[@]} # Show the exit status of those 4 cmds |
| 107 | +0 1 0 0 |
| 108 | +``` |
| 109 | + |
| 110 | +For many years, Fish did not have an equivalent. But in 2019, [ticket |
| 111 | +#2039](https://github.com/fish-shell/fish-shell/issues/2039) was closed and Fish gained |
| 112 | +its own `$pipestatus` variable. |
| 113 | + |
| 114 | +Now, let's demonstrate that same Bash script in Fish: |
| 115 | + |
| 116 | +```fish |
| 117 | +> true | false | true | true |
| 118 | +> echo $pipestatus |
| 119 | +0 1 0 0 |
| 120 | +``` |
| 121 | + |
| 122 | +Behold! The magic of `$pipestatus`! |
| 123 | + |
| 124 | +## Using `$pipestatus` |
| 125 | + |
| 126 | +Now that we know about `$pipestatus`, it might be tempting to test for errors like so: |
| 127 | + |
| 128 | +```fish |
| 129 | +# Oops... this looks like it should work, but it won't! |
| 130 | +true | false |
| 131 | +if test $pipestatus[1] -ne 0 || test $pipestatus[2] -ne 0 |
| 132 | + echo >&2 "An error was found: $pipestatus" # <-- Unreachable code! |
| 133 | +end |
| 134 | +``` |
| 135 | + |
| 136 | +This fails spectacularly! What happened? Remember how we talked about how volatile |
| 137 | +`$status` is? Well, `$pipestatus` is the same - it changes after every new command. In |
| 138 | +the above script, when the first `test` was called, it reset `$status` and |
| 139 | +`$pipestatus` to reflect its own exit status, and then when `test $pipestatus[2]` tries |
| 140 | +to run, `$pipestatus` no longer has 2 elements. |
| 141 | + |
| 142 | +So what's the fix? Store the results of `$pipestatus` **IMMEDIATELY** in a variable, and |
| 143 | +then use that variable to do your error handling. |
| 144 | + |
| 145 | +```fish |
| 146 | +# This works since we save the volatile $pipestatus contents |
| 147 | +true | false |
| 148 | +set --local last_pipestatus $pipestatus # Save ourselves pain! |
| 149 | +if test $last_pipestatus[1] -ne 0 || test $last_pipestatus[2] -ne 0 |
| 150 | + echo >&2 "An error was found: $last_pipestatus" |
| 151 | +end |
| 152 | +``` |
| 153 | + |
| 154 | +Remember - `$status` and `$pipestatus` are weird Schrödinger's variables. They change |
| 155 | +every time you peek in the box. Every. Single. Command. |
| 156 | + |
| 157 | +That's not unique to Fish. Bash has the same behavior: |
| 158 | + |
| 159 | +```bash |
| 160 | +$ true | false | true | true # simulate piping in Bash |
| 161 | +$ echo ${PIPESTATUS[@]} # echo is about to change $PIPESTATUS |
| 162 | +0 1 0 0 |
| 163 | +$ echo ${PIPESTATUS[@]} # AND... it did |
| 164 | +0 |
| 165 | +``` |
| 166 | + |
| 167 | +## Better ways to test $pipestatus |
| 168 | + |
| 169 | +Let's say you have 6 piped commands, do you really have to test each result like this? |
| 170 | + |
| 171 | +```fish |
| 172 | +# Don't do this |
| 173 | +true | false | true | true | false | true |
| 174 | +set --local last_pipestatus $pipestatus |
| 175 | +if test $last_pipestatus[1] -ne 0 |
| 176 | + or test $last_pipestatus[2] -ne 0 |
| 177 | + or test $last_pipestatus[3] -ne 0 |
| 178 | + or test $last_pipestatus[4] -ne 0 |
| 179 | + or test $last_pipestatus[5] -ne 0 |
| 180 | + or test $last_pipestatus[6] -ne 0 |
| 181 | +
|
| 182 | + echo >&2 "error" |
| 183 | +end |
| 184 | +``` |
| 185 | + |
| 186 | +The answer is no. This gets really messy to read and offers no benefit. You could |
| 187 | +choose to treat your array like a string: |
| 188 | + |
| 189 | +```fish |
| 190 | +# Better, but don't do this either |
| 191 | +if test "$last_pipestatus" != "0 0 0 0 0 0" |
| 192 | + echo >&2 "error" |
| 193 | +end |
| 194 | +``` |
| 195 | + |
| 196 | +This is certainly more readable, but what if you miscounted your pipes and got the |
| 197 | +number wrong? This can lead to subtle bugs. Instead, my preferred test for `$pipestatus` |
| 198 | +is `string match -qr '[^0]' $last_pipestatus`. What this does is quietly (`-q`) tests |
| 199 | +every element in our saved `$last_pipestatus` array with a regex (`-r`) pattern looking |
| 200 | +for any non-zero value (`[^0]`). |
| 201 | + |
| 202 | +Here's the full example: |
| 203 | + |
| 204 | +```fish |
| 205 | +# This test works no matter how many things we have piped |
| 206 | +true | false | true | true |
| 207 | +set --local last_pipestatus $pipestatus |
| 208 | +if string match -qr '[^0]' $last_pipestatus |
| 209 | + echo >&2 "Errors were seen: $last_pipestatus" |
| 210 | +end |
| 211 | +``` |
| 212 | + |
| 213 | +## Conclusion |
| 214 | + |
| 215 | +Hopefully this was a helpful introduction to handling exit statuses in Fish. |
0 commit comments