Skip to content

Latest commit

 

History

History
160 lines (134 loc) · 8.99 KB

README.en.md

File metadata and controls

160 lines (134 loc) · 8.99 KB

/dev/tty

中文

Many popular terminal applications actually have one same bug:

   fzf 2> /dev/null

You can't see what fzf gives you to pick from any more. Press Ctrl-C and escape.

   vi > /dev/null

Unless you're some monstrosity that writes alias vi=vscode in your shell's config, what you get is likely this: your shell no longer responds to you. Ctrl-C doesn't help you either.

If you have a good intuition, you might be able to realize that you can :wq then press enter to quit. If you're some "I act before I think" shoujo manga heroine, or some "I act before I think" shounen manga hero, you're done. Your nonsense input have already forced your text editor into an unknown state, and you can't know what that state is, because its output is redirected. You can't get this shell back.

Some people might argue that this is not a bug. Indeed, people normally won't use these applications this way. Whether this counts as a bug depends on how you define a bug. The fact is, you use vi this way, your shell is sabotaged. I don't like this.

If you have some knowledge on terminals, you know what the problem is already. If you don't, let me explain this to you.

The programs that make fancy drawings on you terminal such as vi or fzf don't use magic to control you terminal. They send terminal escape codes to your terminal, in order to tell it what they want it to do. They can also receive some terminal escape codes from the terminal to get some info.

Let's write some C.

#include <stdio.h>
int main()
{
        fputs("\033[2J", stdout); // Clear the terminal.
        fputs("\033[1;1H", stdout); // Move the cursor to row 1, column 1.
        fputs("hello world\n", stdout);
        return 0;
}

All code in this article could sabotage your terminal, so be prepared to reopen it.

Compile and execute. You terminal clears the text on it, and then starts to print "hello world" from row 1 column 1. You shell prints the prompt as usual on your terminal after this small program's done.

The format of terminal escape codes is not standardized. Well, it is (ECMA-48), but no implementation of terminals follows it. On Linux, one convenient way of learning those is man console_codes. This command tells you what codes the Linux console, one implementation of a terminal by Linux itself, supports. Most other terminals support most of these codes. Press Ctrl-Alt-F3 on Linux, what you see is a Linux console. If these codes are not enough for you, here are many other codes. Some codes are supported by some terminals. If you want to ensure that a functionality can be realized, the only way is to try it on a terminal that you want to support.

So, what happens if you run this small program redirected?

    $ ./a.out > /tmp/codes # Assuming the name of the executable is a.out.

Nothing happens to your terminal.

    $ cat /tmp/codes

Printing out this file does what executing the program did before.

Now we know what caused the problems we saw on fzf and vi. They control the terminals by sending the escape codes. fzf sends the codes to stderr. vi sends the codes to stdout. If stderr and stdout happen to be terminals, those applications can be used normally. The 2 streams can be redirected however. If they are redirected to files, the escape codes are written to those files. The terminal never receives those codes, and therefore won't draw following the programs' instructions.

One might argue that one can use isatty to decide whether the stream is a terminal, and just exit if it's not. This doesn't solve the problem completely. Some really evil people can even redirect the streams to other terminals (man pts). Some programs ask stderr what the terminal size is or where the cursor is, and gets the result from stdin. One can connect stderr and stdin to different terminals, and send fake responses to the program, and let the program make some edits at (200, 200) on a buffer representing a 20x20 terminal.

Is this problem solvable then? That's a problem that is not solved by those popular software. Does this mean there're no solutions? The answer is yes, theoretically. Practically? How do I know, I don't maintain those software.

#include <stdio.h>
int main()
{
        FILE *dev_tty = fopen("/dev/tty", "w");
        fputs("\033[2J", dev_tty); // Clear the terminal.
        fputs("\033[1;1H", dev_tty); // Move the cursor to row 1, column 1.
        fputs("hello terminal\n", dev_tty);
        fclose(dev_tty);
        fputs("hello stdout\n", stdout);
        return 0;
}

Unix-like operating systems give a process a special file if it's connected to a terminal /dev/tty. This file is the terminal. Therefore, redirecting standard IO streams won't affect IO with this file.

There is no user-friendly documentation for /dev/tty. You get some documentation by man 4 tty.

Compile and run these several lines of code. If you simply run it, it's not very different from the "hello world" program we tried before. It's just that the output text is different. However, if you redirect the stdout of this program, you'll notice that what's sent to /dev/tty still gets to your terminal, and what's written to you redirection's target file, is just the line of text "hello stdout".

/dev/tty is not just an output handle. It can also be used for input.

#include <stdio.h>

int main()
{
        FILE *dev_tty = fopen("/dev/tty", "r+");
        fputs("\033[2J", dev_tty); // Clear the terminal.
        fputs("\033[1;1H", dev_tty); // Move the cursor to row 1, column 1.
        fputs("hello terminal\nGive me a character > ", dev_tty);
        fflush(dev_tty);
        char c = fgetc(dev_tty);
        fclose(dev_tty);
        fprintf(stdout, "The character I got: %c\n", c);
        return 0;
}

Compile and run. Press enter after inserting one character. If you don't redirect stdout, this program directly tells you what that character is. If you redirected it, it write what you've inserted to the redirection's target.

Yes, even if you've never used stdin, you can still get user input.

This also means, by using /dev/tty, you also allow your user to redirect your program's stdin. You can write a program that is totally usable in a pipe. Imagine having a text editor that can be used in a pipe. It reads from stdin, lets the user edit it from the terminal, and then sends the result to stdout. That's interactive sed.

The program above needs you to press enter after inserting a character because, by default, when the terminal executes your program, there is an "input buffer". In this situation, the terminal sends what it gets to your program only after getting an enter press. man stty to learn more.

/dev/tty can also be used to change this:

#include <stdio.h>
#include <termios.h>
#include <unistd.h>

int main()
{
        FILE *dev_tty = fopen("/dev/tty", "r+");
        struct termios original_termios;
        struct termios raw_termios;
        cfmakeraw(&raw_termios);
        tcgetattr(fileno(dev_tty), &original_termios);
        tcsetattr(fileno(dev_tty), TCSADRAIN, &raw_termios);
        fputs("\033[2J", dev_tty); // Clear the terminal.
        fputs("\033[1;1H", dev_tty); // Move the cursor to row 1, column 1.
        fputs("hello terminal\r\nGive me a character > ", dev_tty);
        fflush(dev_tty);
        char c = fgetc(dev_tty);
        fputs("\r\n", dev_tty);
        tcsetattr(fileno(dev_tty), TCSADRAIN, &original_termios);
        fclose(dev_tty);
        fprintf(stdout, "The character I got: %c\n", c);
        return 0;
}

The program above changes the mode of the terminal temporarily. Now we no longer need to press enter after giving a character. man termios to learn about the system calls used above.

Lots of programs use stdout, stdin or stderr with those system calls. /dev/tty is more terminal than those streams, and therefore of course can be used with the system calls.

Let's try piping as a conclusion.

#include <stdio.h>
#include <termios.h>
#include <unistd.h>

int main()
{
        char c_stdin = fgetc(stdin);
        FILE *dev_tty = fopen("/dev/tty", "r+");
        struct termios original_termios;
        struct termios raw_termios;
        cfmakeraw(&raw_termios);
        tcgetattr(fileno(dev_tty), &original_termios);
        tcsetattr(fileno(dev_tty), TCSADRAIN, &raw_termios);
        fputs("\033[2J", dev_tty); // Clear the terminal.
        fputs("\033[1;1H", dev_tty); // Move the cursor to row 1, column 1.
        fputs("hello terminal\r\nGive me a character > ", dev_tty);
        fflush(dev_tty);
        char c_tty = fgetc(dev_tty);
        fputs("\r\n", dev_tty);
        fprintf(dev_tty, "The character I got from tty: %c\r\n", c_tty);
        tcsetattr(fileno(dev_tty), TCSADRAIN, &original_termios);
        fclose(dev_tty);
        fprintf(stdout, "The character I got stdin: %c\n", c_stdin);
        return 0;
}
    $ printf s | ./a.out | cat > /tmp/stdout