Search code examples
node.jsbashshellunixtty

How do shells handle TTY?


I'm writing a dummy shell in nodejs to figure out how they work, and have realised a hole in my understanding. Specifically, in NodeJS, you can detect a shell in TTY mode using process.<stream>.isTTY. But if I spawn my nodejs instance using the following command

import cp from 'node:child_process';

const proc = cp.spawn('node', ['-p', 'Boolean(process.stdin.isTTY)']);

process.stdin.pipe(proc.stdin);
proc.stdout.pipe(process.stdout);
proc.stderr.pipe(process.stderr);

await new Promise((ok, err) => proc.once('exit', code => code == 0 ? ok() : err()));

Whereas using { stdio: 'inherit' } does some magic which causes the filedescriptors to be passed down to the child process, and along with it, the TTY mode.

import cp from 'node:child_process';

const proc = cp.spawn('node', ['-p', 'Boolean(process.stdin.isTTY)'], { stdio: 'inherit' });

await new Promise((ok, err) => proc.once('exit', code => code == 0 ? ok() : err()));

Here, the value true is printed, whereas above, either false or undefined. This means that there is a difference between piping streams and TTY modes.

My question is specifically about how shells such as BASH or FISH handle this, and what the actual distinction is.

How do child processes inherit this TTY mode, and what happens when the user wishes to pipe one process to another. How do shells which allow you to split panes work? I'm not referring to terminal emulators such as XTerm or Konsole, but the actual shell which can display two simultaneous processes which both seem to be TTY enabled.


Solution

  • We'll leave the split panes for the moment. Also, don't think about inheriting TTY-modes; in the context of shells that concept doesnot really have any value.

    I'll be doing bash, because I don't know fish.

    You can test if a shell is interactive with

    [[ $- == *i* ]] && echo 'Interactive' || echo 'not-interactive'
    

    If you pipe to a shell, the shell becomes non-interactive:

    $ cat | bash
    [[ $- == *i* ]] && echo 'Interactive' || echo 'not-interactive'
    not-interactive
    

    On the other hand, [ -t 0 ] tests whether your shell reads from a tty. Consider the following script:

    #!/bin/bash
    [[ $- == *i* ]] && echo 'Interactive' || echo 'not-interactive'
    [ -t 0 ] && echo 'connected to tty' || echo 'not connected to tty'
    

    You can run this in different ways. In the current shell:

    $ . test.sh 
    Interactive
    connected to tty
    

    As a script:

    $ bash test.sh
    not-interactive
    connected to tty
    

    or on a pipe:

    $ echo | bash test.sh
    not-interactive
    not connected to tty
    

    or force interactive with -i:

    $ echo | bash -i test.sh
    Interactive
    not connected to tty
    

    So, what determines if a shell is interactive? According to the source-code:

         A shell is interactive if the `-i' flag was given, or if all of
         the following conditions are met:
            no -c command
            no arguments remaining or the -s flag given
            standard input is a terminal
            standard error is a terminal
    

    During the start-up, bash tests this:

      if (forced_interactive ||             /* -i flag */
          (!command_execution_string &&     /* No -c command and ... */
           wordexp_only == 0 &&             /* No --wordexp and ... */
           ((arg_index == argc) ||          /*   no remaining args or... */
            read_from_stdin) &&             /*   -s flag with args, and */
           isatty (fileno (stdin)) &&       /* Input is a terminal and */
           isatty (fileno (stderr))))       /* error output is a terminal. */
        init_interactive ();
      else
        init_noninteractive ();
    

    So, bash determines it at the start-up of the shell.

    The point is, that the TTY is not really handled differently. Sure, you can test if the shell is interactive; you can test if the shell reads from a TTY, but that's it. The TTY is just a file descriptor for the shell. In the source code, you can see that there are tests whether STDIN is a TTY, but for the rest: the shell uses STDIN, not a TTY as input. The assumption that TTYs are treated differently is wrong.