The Problem

Our team manages an internal CLI tool that performs various operations. It's a handy tool written in Go, using the pterm package for its interactive CLI capabilities. The tool will also prompts users to select from a list of options when necessary parameters are missing.

However, when developers tried to use the tool in scripts and CI environments, they encountered an issue. In these non-interactive environments, the tool would hang indefinitely, waiting for user input that would never come. This caused scripts and CI pipelines to hang instead of fail immediately leading to frustration and confusion.

So my goal is to prevent the process from hanging, if not enough parameter is provided, it should exit with an error.

Initial Thoughts and Failed Attempts

My first attempt to solve the problem was to implement a global flag that would disable the interactive mode. We should checking this flag before invoking any interactive select function. However, this approach had several drawbacks:

  1. The logic to check the flag would need to be scattered throughout the codebase, leading to duplication and making it a huge, ugly project.
  2. There was no way to enforce the flag check for new code added in the future, risking the issue reappearing.

Next, I explored the possibility of extending the pterm package to change its behavior, allowing us to leave the existing code untouched. Unfortunately, due to the lack of override support in Go, this route proved to be very difficult and impractical.

The Breakthrough

While trying to figure out a solution, I accidentally observed an intriguing output when running the tool in the background:

$ mytool mycmd &
51326 suspended (tty output)  mytool mycmd

The phrase suspended (tty output) caught my attention. After some research, I discovered that the process was being killed due to receiving a SIGTTOU signal.

Macro: int SIGTTIN

A process cannot read from the user’s terminal while it is running as a background job. When any process in a background job tries to read from the terminal, all of the processes in the job are sent a SIGTTIN signal. The default action for this signal is to stop the process. For more information about how this interacts with the terminal driver

Macro: int SIGTTOU

This is similar to SIGTTIN, but is generated when a process in a background job attempts to write to the terminal or set its modes. Again, the default action is to stop the process. SIGTTOU is only generated for an attempt to write to the terminal if the TOSTOP output mode is set;

Job Control Signals (The GNU C Libray)

So in Linux, when a background process attempts to write to the terminal, it is sent a SIGTTOU signal, which by default stops the process.

This is exactly what happened here, the & puts the process in background. And when this bg process try to write the interactive messages in Terminal, linux sends a SIGTTIN to the process leading it to stop.

I also did a quick validation for input as well

def echo():
    user_input = input()
    print("You entered:", user_input)

if __name__ == "__main__":
    echo()
$ python3 echo.py &
[9] 27418
[9]  + 27418 suspended (tty input)  python3 echo.py

Apparently this process is killed when trying to access tty input.