Bash – Wait for a process to finish OR for the user to press a key

bashfunctionshell-scriptuser input

I need two ways to terminate a part of my bash script.

Either a counter reaches a predefined number, or the user manually forces the script to continue with whatever the value the counter currently has.

Specifically – I'm listing USB drives. If there is 15 of them, the function that counts them exits and the script can continue.

My code looks a bit like this:

scannew(){
    NEW=0
    OLD=$NEW
    while [ true ]; do
        # count the new drives
        lsblk -r > drives.new
        diff drives.old drives.new | grep disk | cut -d' ' -f 2 | sort > drives.all
        NEW=$(wc -l drives.all | cut -d' ' -f1)
        echo -en "   Detected drives: $NEW    \r"
        sleep 0.01
        if [ "$NEW" -eq "15" ]; then # exit if we reach the limit
            break
        fi
    done
}

# SOME CODE...

lsblk -r > drives.old

scannew & # start live device counter in the background
SCAN_PID=$! # remember it's PID
wait $SCAN_PID 2>/dev/null # wait until it dies
echo "It's on!"

# REST OF THE CODE...

I tried various stuff with the read command, but the result is, the script will always wait for read to exit (after pressing ENTER) and I can't make the "15 limit" condition to override that.

For example I tried using read -t instead of sleep in the scannew() function:

scannew(){
    NEW=0
    OLD=$NEW
    while [ true ]; do

        # count the new drives
        lsblk -r > drives.new
        diff drives.old drives.new | grep disk | cut -d' ' -f 2 | sort > drives.all
        NEW=$(wc -l drives.all | cut -d' ' -f1)
        echo -en "   Detected drives: $NEW    \r"
        read -t 0.01 -n 1 && break # read instead of sleep
        if [ "$NEW" -eq "15" ]; then
            break
        fi
    done
}

However – it seems that the function subprocess doesn't have access to stdin, and using read -t 0.01 -n 1 < /dev/stdin && break instead didn't work either.

How can I make this work?

Best Answer

Let me start by saying, you could just inline all the stuff you have in scannew, since you're waiting anyway, unless you intend to scan again at some other point in your script. It's really the call to wc that you're concerned might take too long, which, if it does, you can just terminate it. This is a simple way to set that up using trap which allows you to capture signals sent to a process and set your own handler for it:

#! /usr/bin/env bash

# print a line just before we run our subshell, so we know when that happens
printf "Lets do something foolish...\n"

# trap SIGINT since it will be sent to the entire process group and we only
# want the subshell killed
trap "" SIGINT

# run something that takes ages to complete
BAD_IDEA=$( trap "exit 1" SIGINT; ls -laR / )

# remove the trap because we might want to actually terminate the script
# after this point
trap - SIGINT

# if the script gets here, we know only `ls` got killed
printf "Got here! Only 'ls' got killed.\n"

exit 0

However, if you want to retain the way you do things, with scannew being a function run as a background job, it takes a bit more work.

Since you want user input, the proper way to do it is to use read, but we still need the script to go on if scannew completes and not just wait for user input forever. read makes this a bit tricky, because bash waits for the current command to complete before allowing traps to work on signals. The only solution to this that I know of, without refactoring the entire script, is to put read in a while true loop and give it a timeout of 1 second, using read -t 1. This way, it'll always take at least a second for the process to finish, but that may be acceptable in a circumstance like yours where you essentially want to run a polling daemon that lists usb devices.

#! /usr/bin/env bash

function slow_background_work {
    # condition can be anything of course
    # for testing purposes, we're just checking if the variable has anything in it
    while [[ -z $BAD_IDEA ]]
    do
        BAD_IDEA=$( ls -laR / 2>&1 | wc )
    done

    # `$$` normally gives us our own PID
    # but in a subshell, it is inherited and thus
    # gives the parent's PID
    printf "\nI'm done!\n"
    kill -s SIGUSR1 -- $$
    return 0
}

# trap SIGUSR1, which we're expecting from the background job
# once it's done with the work we gave it
trap "break" SIGUSR1

slow_background_work &

while true
do
    # rewinding the line with printf instead of the prompt string because
    # read doesn't understand backslash escapes in the prompt string
    printf "\r"
    # must check return value instead of the variable
    # because a return value of 0 always means there was
    # input of _some_ sort, including <enter> and <space>
    # otherwise, it's really tricky to test the empty variable
    # since read apparently defines it even if it doesn't get input
    read -st1 -n1 -p "prompt: " useless_variable && {
                              printf "Keypress! Quick, kill the background job w/ fire!\n"
                              # make sure we don't die as we kill our only child
                              trap "" SIGINT
                              kill -s SIGINT -- "$!"
                              trap - SIGINT
                              break
                            }
done

trap - SIGUSR1

printf "Welcome to the start of the rest of your script.\n"

exit 0

Of course, if what you actually want is a daemon that watches for changes in the number of usb devices or something, you should look into systemd which might provide something more elegant.

Related Question