3

I have a text-based user interface script that allows me to browse directories and select a file. The graphics are output to stderr, the chosen file's path is sent to stdout. This allows to get the chosen file this way:

file="$(./script)"

This is very handy, as command substitution only grabs stdout.

But I need my script to handle signals, so that when the script is interrupted, it can reset the display. I set up a trap that handles the INT signal. To simulate what it's doing, consider the following script:

catch() { 
    echo "caught"
    ps # Calling an external command
    exit
}

trap catch INT

while read -sN1; do # Reading from the keyboard
    echo $REPLY >&2
done

The script is then called with var="$(./script)". Now, if you send the INT signal by hitting ^C, the parent shell breaks: Anything you type (including control characters) will be printed out until you hit return, then none of your inputs will be shown.

Removing the external command call in the catch function seems to fix the issue (still, the echo doesn't seem to work), but I don't understand why, and I can't do without it in my final script.

Is there something I'm missing? Why does this breaks the parent shell?

agc
  • 7,973
  • 2
  • 29
  • 50
Informancien
  • 255
  • 3
  • 13
  • Running `stty icanon echo echok` afterwards fixes it. Apparently `read -sN1` changes terminal settings but doesn't restore them back when interrupted. This might be a bug, consider reporting it. – oguz ismail Apr 15 '20 at 17:31
  • I'm unable to reproduce this. When I hit ^C, both scripts exit and the shell behaves perfectly normally. This is on Bash 5.0.16(1)-release – that other guy Apr 15 '20 at 17:40
  • @thatotherguy You ran `file="$(./script)"` right? I can reproduce this on 5.0.16(2)-maint (devel branch, latest push) – oguz ismail Apr 15 '20 at 17:43
  • I ran `./runner` which was a script that did `var="$(./script)"`. I see now that if I run `file="$(./script)"` directly from an interactive terminal, that terminal is messed up afterwards. Is it not supposed to be part of a script? – that other guy Apr 15 '20 at 17:54
  • @that *I have a text-based user interface script that allows me to browse directories and select a file.* sounds like a script intended for interactive use to me – oguz ismail Apr 15 '20 at 18:01
  • `file="$(./script)` should be able to run on an interactive shell for what I need. I'm currently running Bash 4.4.20(1)-release. `stty icanon echo echok` doesn't work if it is part of the `catch` function, it does only if run right after sending `INT` and exiting the script. – Informancien Apr 15 '20 at 18:05

2 Answers2

2

My unverified but best theory is that this is caused by a race between the Parent reading the terminal settings, and the Child restoring them.

When interrupted, the interactive shell will stop trying to read from the pipe, and carefully check the current terminal settings to avoid clobbering them later. If the child hasn't restored them yet, the parent will read the bad settings and assume that's how the terminal is supposed to be.

This is explains why you can type one line before it starts messing up: the child has restored the good settings to buffered canonical mode, so you can type a full line. Once you hit enter, bash gets the command, and as part of its prompting restores the bad settings it thought the terminal was supposed to have.

To get around this, you could have the parent handle SIGINT for the duration of the capture. It doesn't matter what the handler does, because the only point is to cause Bash to wait for current commands to finish so it can invoke the handler.

Here's an example:

#!/bin/bash

catch() {
  sleep 1 # Make sure to lose the race
  echo "caught"
  ps
  exit
}

trap catch INT

while read -sN1; do # Reading from the keyboard
    echo $REPLY >&2
done

and here's the interactive shell after typing x and hitting Ctrl-C:

bash-5.0$ trap 'true' INT; var=$(./script)
x
bash-5.0$ echo "The prompt works fine"
The prompt works fine
bash-5.0$ declare -p var
declare -- var="caught
    PID TTY          TIME CMD
 650388 pts/3    00:00:00 bash
 650859 pts/3    00:00:00 script
 650862 pts/3    00:00:00 ps"
bash-5.0$

Here it is without the trap in the parent, showcasing how only the first command up until the first enter works, while the rest of the input is hidden:

bash-5.0$ trap - INT; var=$(./script)
x

bash-5.0$ echo "I can see this first line"
I can see this first line
bash-5.0$ bash: fasdfasdfasdfasdfa: command not found
that other guy
  • 116,971
  • 11
  • 170
  • 194
  • It works on 5.0.11 for me. My other theory that I wasn't really able to demonstrate was that the `echo` caused a sigpipe that killed the shell before it could reset the terminal, which `( echo "caught")` might have fixed – that other guy Apr 16 '20 at 18:14
  • The main shell is not responsible for restoring the TTY after a command has executed. Putting echo in a subshell would make sure `./script` does not receive a sigpipe in the event that the pipe is closed – that other guy Apr 16 '20 at 18:54
  • Yes, every program is responsible for undoing the changes they make, including bash. My theory is that bash accidentally captures a bad state and then keeps restoring that instead of the good one. I don't know why this example works reliably for me but not for you – that other guy Apr 16 '20 at 19:22
  • For me that only happens when no trap is set in the parent – that other guy Apr 17 '20 at 15:31
  • @oguzismail I reported the bug today, if I get an efficient way to fix it, I'll post it here. – Informancien Apr 17 '20 at 18:30
  • According to my interpretation of events, `read` is working fine – that other guy Apr 17 '20 at 18:40
0

As other users seemed to agree that this is a bug, I filed a bug report. I got the following answer:

This is a race condition -- the parent shell handles the SIGINT before it should. This will be fixed in the next devel branch push.

So the best thing to do here is to keep an eye out on Bash's git.

As a "fix", I had to refactor the script to be sourced (. script.sh), so that it could communicate with the caller without involving temporary files, as process substitution resulted in the exact same behavior than command substitution.

Informancien
  • 255
  • 3
  • 13