4

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?

5
  • 1
    I'm confused about read -t 0.01 -n 1 || break: Don't you want to break on user input? But I thought read returns zero on receiving input, so you should use && instead.
    – Philippos
    Commented Mar 15, 2017 at 13:37
  • You're right. I made a mistake when re-writing the code from memory. Of course it should be &&. Correcting.
    – unfa
    Commented Mar 15, 2017 at 13:51
  • Do you need to run the scanning loop in the background? Background processes usually get stopped if the try to read from the terminal, and you're just waiting for it immediately after anyway.
    – ilkkachu
    Commented Mar 15, 2017 at 13:53
  • 2
    You can use read but place it in the main script - once a key is pressed, you can send a signal to the background process, using kill. With trap you can handle the signal any way you want (I'd recommend using USR1 or USR2). Since scannew does nothing more than scan the devices, your trap can just do return 0. AFAIK you can set up a trap from within a function so no worries there. Just make sure you define the trap at the start of scannew, just in case. Reading ilkkachu's message, I think you should check if scannew is actually doing something when it's taking time.
    – mechalynx
    Commented Mar 15, 2017 at 14:01
  • 1
    @MechaLynx you should make your comment an answer. Commented Mar 15, 2017 at 14:15

2 Answers 2

4

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.

1
  • I had to add a trap "break" SIGINT in the beginning of slow_background_process() and move the kill -s SIGUSR1 -- $$ inside the while loop, but it's working!
    – unfa
    Commented Mar 17, 2017 at 10:00
1

A (somewhat) general solution for running a given command and killing it should there be input from the user, otherwise exiting. The gist is to perform the read in a somewhat-raw terminal mode looking for the "any" key, and to handle the run-program-exits case via the SIGCHLD signal that should be sent when the child exits (assuming no funny business from the child process). Code and docs (and eventual tests).

#ifdef __linux__
#define _POSIX_SOURCE
#include <sys/types.h>
#endif

#include <err.h>
#include <fcntl.h>
#include <getopt.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sysexits.h>
#include <termios.h>
#include <unistd.h>

int Flag_UserOkay;              // -U

struct termios Original_Termios;
pid_t Child_Pid;

void child_signal(int unused);
void emit_help(void);
void reset_term(void);

int main(int argc, char *argv[])
{
    int ch, status;
    char anykey;
    struct termios terminfo;

    while ((ch = getopt(argc, argv, "h?U")) != -1) {
        switch (ch) {
        case 'U':
            Flag_UserOkay = 1;
            break;
        case 'h':
        case '?':
        default:
            emit_help();
            /* NOTREACHED */
        }
    }
    argc -= optind;
    argv += optind;

    if (argc == 0)
        emit_help();

    if (!isatty(STDIN_FILENO))
        errx(1, "must have tty to read from");

    if (tcgetattr(STDIN_FILENO, &terminfo) < 0)
        err(EX_OSERR, "could not tcgetattr() on stdin");

    Original_Termios = terminfo;

    // cfmakeraw(3) is a tad too raw and influences output from child;
    // per termios(5) use "Case B" for quick "any" key reads with
    // canonical mode (line-based processing) and echo turned off.
    terminfo.c_cc[VMIN] = 1;
    terminfo.c_cc[VTIME] = 0;
    terminfo.c_lflag &= ~(ICANON | ECHO);

    tcsetattr(STDIN_FILENO, TCSAFLUSH, &terminfo);
    atexit(reset_term);

    signal(SIGCHLD, child_signal);

    Child_Pid = fork();

    if (Child_Pid == 0) {       // child
        close(STDIN_FILENO);
        signal(SIGCHLD, SIG_DFL);
        status = execvp(*argv, argv);
        warn("could not exec '%s' (%d)", *argv, status);
        _exit(EX_OSERR);

    } else if (Child_Pid > 0) { // parent
        if ((status = read(STDIN_FILENO, &anykey, 1)) < 0)
            err(EX_IOERR, "read() failed??");
        kill(Child_Pid, SIGTERM);

    } else {
        err(EX_OSERR, "could not fork");
    }

    exit(Flag_UserOkay ? 0 : 1);
}

void child_signal(int unused)
{
    // might try to pass along the exit status of the child, but that's
    // extra work and complication...
    exit(0);
}

void emit_help(void)
{
    fprintf(stderr, "Usage: waitornot [-U] command [args ..]\n");
    exit(EX_USAGE);
}

void reset_term(void)
{
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &Original_Termios);
}

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .