1

I have a problem where a parent process wants input from the TTY, but the child process, being bash, which ignores SIGTTIN, keeps running and interferes. Is there a way to get bash to play nicely?

Details

This is on Alpine Linux 3.9 with bash 4.4.19.

For simplification, let's say we have the command wrapper, which does some stuff and then forks a child command. So wrapper bash essentially runs bash as a subshell.

In my situation, wrapper is itself wrapped in a function, like this:

function wrap() {
    wrapper bash -l
}

So I'm running bash at $SHLVL 1 and I type wrap which puts me at $SHLVL 2. I'm working away in the subshell with the enhancements wrapper provides. I am using the bash subshell as a regular interactive shell, so I still need job control. Disabling job control with set +m or set +o monitor is not acceptable.

The problem comes when wrapper wants to read from the TTY. It tries to read and it gets a SIGTTIN. At this point I'm kicked back into $SHLVL 1 with wrapper in the background. Unfortunately, the $SHLVL 2 bash did not get the signal, and is still outputting shell prompts, trying to read from the TTY, but is now getting EOFs causing it to quit if I'm not careful (and maybe lucky) so that my first characters are fg and bring wrapper to the foreground and reading from TTY.

This is a precarious situation and I would like something robust. I just don't know what to do. I'm on a non-graphic terminal, so I cannot just open another window. I could not get openvt to work, I guess because it expects to be running in a graphic environment. I tried writing a bash script like

#!/bin/bash -m
trap "echo parent TTIN" TTIN
bash &
wait $!

but that did not successfully wait for the bash shell to end. It returned immediately.

What I would like to have happen is that when wrapper wants to read from the terminal, the child process is suspended, and then when wrapper puts itself in the background again, the child process resumes. I'm willing to change the way wrapper is launched, or have it launch another wrapper script that then launches bash, but I have no control over wrapper itself.

  • My first question would be why wrap Bash, and the second why the wrapper needs to read input from the user. Also, you can't open other windows, but you should have 64 or so VTs you can use, have you tried changing to another one of them (Ctrl-Alt-FN and then Alt-Left/Right)? – l0b0 Apr 8 at 3:03
  • @l0b0 I tried using another VT using openvt as I said in the post, and also tried chvt. Neither worked. Ctrl-Alt-F is not mapped to anything on my keyboard. – Old Pro Apr 8 at 3:57
0

If I understood your description correctly, I daresay that the wrapper program is not designed to spawn children that are interactive, otherwise it would take care of stopping (SIGSTOP) its children before accessing the tty and then resuming them (SIGCONT) as soon as it has finished with the tty. Apparently that is not the case, if it expects to be allowed to access the tty arbitrarily.

It can be fairly easy to make a helper program to be put between your SHLVL=1 and wrapper thus acting as cushion layer between the two so that your first shell does not detect wrapper being stopped; then this helper program would detect when wrapper gets stopped, and on such event it would stop wrapper’s children, give the tty back to wrapper and resume it. But then it wouldn’t be as well as easy to detect when wrapper has finished with the tty without active cooperation (i.e. notification) by wrapper itself. In fact, given the described behavior, I suspect wrapper actually does not put itself in the background nor does anything more than just going to sleep onto some blocking system-call.

However, if it really puts itself in the background, then the best you can do is to have the helper program continuously poll the tty about the current foreground process, and when that changes back to being wrapper's child, the helper program resumes it (if wrapper doesn’t do so itself, as I suspect).

That is, in general, for resuming the child, I’m afraid you need some specific event (or sequence of events), detectable from outside of wrapper, with which you can correctly infer that wrapper has indeed finished with the tty, and on such event(s) resume wrapper’s child.

For a case where it can be reasonable to have a solution where you manually resume the wrapper's child, here is a sample Python program that should handle the specific case:

#!/usr/bin/python3

import os
import sys
import signal


def main():
    if len(sys.argv) < 2:
        sys.exit(0)

    def _noop_handler(sig, frame):
        """signal handler that does nothing"""
        pass

    termination_signals = {signal.SIGHUP, signal.SIGINT, signal.SIGTERM}
    management_signals = {signal.SIGCHLD, signal.SIGCONT, signal.SIGTTIN,
                          signal.SIGUSR1, signal.SIGUSR2}
    signal.pthread_sigmask(
            signal.SIG_BLOCK,
            management_signals | termination_signals
    )

    child = os.fork()

    if child == 0:  # child process after fork
        signal.sigwait({signal.SIGUSR1})  # wait go-ahead signal from parent
        signal.pthread_sigmask(
                signal.SIG_UNBLOCK,
                management_signals | termination_signals
        )
        os.execvp(sys.argv[1], sys.argv[1:])  # run command
    elif child > 0:  # parent process after fork
        # I want to manipulate tty ownership freely, so ignore SIGTTOU
        signal.signal(signal.SIGTTOU, signal.SIG_IGN)
        # A handler for SIGCHLD is required on some systems where semantics
        # for ignored signals is to never deliver them even to sigwait(2)
        signal.signal(signal.SIGCHLD, _noop_handler)

        in_fd = sys.stdin.fileno()
        my_pid = os.getpid()
        ppid = os.getppid()
        os.setpgid(child, child)  # put child in its own process group
        if os.tcgetpgrp(in_fd) == my_pid:
            # if I have been given the tty, hand it over to child
            # This is not the case when shell spawned me in "background" &
            os.tcsetpgrp(in_fd, child)
        os.kill(child, signal.SIGUSR1)  # all set for child, make it go ahead
        last_robbed_group = 0
        # signals to care for child
        io_wanted_signals = {signal.SIGTTIN, signal.SIGTTOU}

        def _send_sig(_pgid, _sig, accept_myself=False) -> bool:
            """
            send a signal to a process group if that is not my own or
            if accept_myself kwarg is True, and ignore OSError exceptions
            """
            if not accept_myself and _pgid == my_pid:
                return True
            try:
                os.killpg(_pgid, _sig)
            except OSError:
                return False
            return True

        def _resume_child_if_appropriate():
            """
            resume child unless that would steal tty from my own parent
            """
            nonlocal last_robbed_group
            fg_group = os.tcgetpgrp(in_fd)
            if fg_group == os.getpgid(ppid):
                # Minimal protection against stealing tty from parent shell.
                # If this would be the case, rather stop myself too
                _send_sig(my_pid, signal.SIGTTIN, accept_myself=True)
                return
            # Forcibly stop current tty owner
            _send_sig(fg_group, signal.SIGSTOP)
            if fg_group not in {os.getpgid(child), my_pid}:
                # remember who you stole tty from
                last_robbed_group = fg_group
            # Resume child
            os.tcsetpgrp(in_fd, os.getpgid(child))
            _send_sig(os.getpgid(child), signal.SIGCONT)

        waited_signals = termination_signals | management_signals
        while True:
            # Blocking loop over wait for signals
            sig = signal.sigwait(waited_signals)
            if sig in termination_signals:
                # Propagate termination signal and then exit
                _send_sig(os.getpgid(child), sig)
                os.wait()
                sys.exit(128 + sig)
            elif sig == signal.SIGCONT:
                # CONT received, presumably from parent shell, propagate it
                _resume_child_if_appropriate()
            elif sig == signal.SIGTTIN:
                # TTIN received, presumably from myself
                prev_fg = os.tcgetpgrp(in_fd)
                # Stop current tty owner if not my own parent
                if prev_fg != os.getpgid(ppid):
                    _send_sig(prev_fg, signal.SIGSTOP)
                try:
                    # Give tty back to my own parent and stop myself
                    os.tcsetpgrp(in_fd, os.getpgid(ppid))
                    _send_sig(my_pid, signal.SIGSTOP, accept_myself=True)
                except OSError:
                    try:
                        # ugh, parent unreachable, restore things
                        os.tcsetpgrp(in_fd, prev_fg)
                        _send_sig(prev_fg, signal.SIGCONT)
                    except OSError:
                        # Non-restorable situation ? let's idle then
                        os.tcsetpgrp(in_fd, my_pid)
            elif sig == signal.SIGCHLD:
                # Event related to child, let's investigate it
                pid, status = os.waitpid(child, os.WNOHANG | os.WUNTRACED)
                if pid > 0:
                    if os.WIFSIGNALED(status):
                        # Child terminated by signal, let's propagate this
                        sys.exit(128 + os.WTERMSIG(status))
                    elif os.WIFEXITED(status):
                        # Child exited normally, let's propagate this
                        sys.exit(os.WEXITSTATUS(status))
                    elif os.WIFSTOPPED(status) and \
                            os.WSTOPSIG(status) in io_wanted_signals:
                        # Child got stopped trying to access the tty, resume it
                        _resume_child_if_appropriate()
            elif sig in {signal.SIGUSR1, signal.SIGUSR2} \
                    and last_robbed_group:
                # Management signals to resume robbed process
                if sig == signal.SIGUSR2:
                    # Forcibly stop child, whatever it is doing or not doing
                    _send_sig(os.getpgid(child), signal.SIGSTOP)
                try:
                    # resume robbed process
                    os.tcsetpgrp(in_fd, last_robbed_group)
                    os.killpg(last_robbed_group, signal.SIGCONT)
                except OSError:
                    # Robbed process no longer exists ? oh well..
                    last_robbed_group = 0
                    try:
                        # resume child then
                        os.tcsetpgrp(in_fd, os.getpgid(child))
                        os.killpg(os.getpgid(child), signal.SIGCONT)
                    except OSError:
                        pass


if __name__ == '__main__':
    main()

It requires at least Python v3.3.

It is not well engineered: it is made of one main function with a couple of sub-functions, but the purpose is to be as more readable and understandable as possible while still providing the basic amount of necessary features.

Also, it could be expanded to e.g. play nicely with shells that are not its direct parent, or with possible recursive invocations of the same program, or also with possible race conditions when querying the current foreground process and later changing it, and probably other corner cases.

The above program automatically stops the current tty owner and resumes wrapper if it gets stopped due to it accessing the tty while not allowed to. In order to manually resume the previous tty owner I made provision for two choices:

  1. a soft resume: the preferable way when you are sure that wrapper finished with the tty; send a SIGUSR1 to the helper-program and it will just resume the previous tty owner
  2. a hard resume: the way to use when you want to stop wrapper regardless; send SIGUSR2 to the helper-program and it will SIGSTOP wrapper before resuming the previous tty owner

You may also send SIGCONT to the helper-program: it will forcibly stop the current tty owner and resume wrapper regardless.

With this setup, in general you should better avoid sending STOP/CONT signals directly to either wrapper or any of its children or sub-children.

In all cases, always keep in mind that you’re playing with what is usually a delicate interaction between "foreign" programs and their controlled jobs, especially when you invoke interactive shells within interactive shells. These often do not like to be SIGSTOP-ed and SIGCONT-ed arbitrarily. Therefore you often need to carefully apply the correct sequence of operations in order to not have them react by exiting abruptly or messing the terminal window.

  • You are correct, the wrapper program was designed to run short-lived programs and not to require input while they ran. Would you provide an example shell script for the "fairly easy" part: detect when wrapper gets stopped, and on such event stop wrapper’s children, give the tty back to wrapper and resume it? I'm fine with manually running a second script to put wrapper back to sleep and resume the child. – Old Pro Apr 13 at 22:48
  • @OldPro Unfortunately “fairly easy” here does not mean “doable with a shell script”, at least not with Bash. I don’t know about more advanced shells like zsh, perhaps you might have a look at that. By “fairly easy” I meant “not very long nor greatly complicated”, but I was however implying the use of programming languages that allow access to system-calls such as waitpid(2) and tcsetpgrp(3) and the likes. Certainly C/C++ allow them, but also higher-level languages like Python with its ‘os’ library or system-level languages like Rust – LL3 Apr 15 at 12:21
  • @OldPro Also, it would not be possible to use a script to manually resume wrapper’s child either, because in order to manipulate tty’s fg/bg processes from a program you need such program to be member of that tty’s session. Hence basically the overall helper program should do it. For instance you could make provision of an interface (e.g. signal delivered via normal kill) to the helper program for this purpose.. and also consider system compatibility because these low-level manipulations can vary among different UNIX systems, and on Linux even among historical kernel versions. – LL3 Apr 15 at 12:23
  • A Python or PERL script would be fine, and control via signals would be OK, too. The wrapper needs to be running in the background, so I cannot just wait on it, and any program or script parent is not going to get the TSTP signal, so I don't see how to monitor the children and act accordingly. It doesn't need to be portable, as I'm just using it on my Apline-based Docker container. – Old Pro Apr 15 at 16:15
  • @OldPro Answer updated. I understand that in a (possibly lean) container context an independent executable would be better suited, but I finally chose Python for better readability and portability and to get rid of the ancillary data-structures and operations that are necessary in C. I tried to make a compromise between a simple proof-of-concept and a fully fledged robust solution, and I think it can make for an actually usable program for most situations. Have a look, and I’d be glad to know how it went – LL3 Apr 17 at 18:14

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.