Implementing the script Program Pseudoterminals

 

Chapter 64 – Pseudoterminals
Part 1: Implementing the script Program
Topic
PTY + script
Level
Advanced
Book
TLPI Ch.64

What is the script Program?

The Unix script command records everything you type and everything printed on your terminal into a file called typescript. It is the classic real-world example of how pseudoterminals (PTYs) are used in practice.

Think of it like this: normally your keyboard goes directly to the shell, and the shell’s output goes directly to your screen. The script program sits in the middle using a PTY — it intercepts all traffic and copies it to a file while still letting everything work normally.

Your Keyboard / Screen
(real terminal: /dev/pts/1)
script process
(PTY master fd)
+ select() loop
Shell (child)
(/dev/pts/24 slave)
↓ also writes to ↓
typescript file (disk record of entire session)

Key Terms in This Part

ptyFork() pseudoterminal master/slave raw mode select() tcgetattr() ttySetRaw() atexit() TIOCGWINSZ execlp() typescript

Step 1 – Read Terminal Attributes and Window Size

Before forking, the script program reads the current terminal’s settings (termios) and window size (winsize). These are saved and later passed to ptyFork() so the slave PTY device is configured to match the user’s real terminal.

Why? Because the shell running inside the PTY slave must see the correct terminal settings from day one — otherwise backspace, Ctrl+C, echo, and line editing would all behave wrongly.


#include <termios.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdio.h>

struct termios ttyOrig;   /* save original settings here */

int main(void)
{
    struct winsize ws;

    /* Read terminal line discipline settings */
    if (tcgetattr(STDIN_FILENO, &ttyOrig) == -1) {
        perror("tcgetattr");
        return 1;
    }

    /* Read terminal window dimensions */
    if (ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) < 0) {
        perror("ioctl TIOCGWINSZ");
        return 1;
    }

    printf("Rows: %d, Cols: %d\n", ws.ws_row, ws.ws_col);
    /* ws and ttyOrig are now ready to be passed to ptyFork() */
    return 0;
}
    

Key point: tcgetattr() reads line-discipline flags like echo, canonical mode, signal handling characters (Ctrl+C, Ctrl+Z), etc. TIOCGWINSZ gives rows and columns so the child shell knows how wide to wrap output.

Step 2 – Call ptyFork() to Create Child via PTY

ptyFork() does a fork() internally. After the fork:

  • The parent gets a file descriptor for the PTY master.
  • The child has its stdin, stdout, and stderr connected to the PTY slave device.

The slave device name (like /dev/pts/24) is also returned so we can display or log it.

Before ptyFork() — single process
↓ fork()
Parent (script)
masterFd = open PTY master
childPid = PID of child
Child
stdin/stdout/stderr → PTY slave
slaveName = “/dev/pts/24”

#include "pty_fork.h"   /* custom header from TLPI */

#define MAX_SNAME 1000

char slaveName[MAX_SNAME];
int  masterFd;
pid_t childPid;

childPid = ptyFork(&masterFd, slaveName, MAX_SNAME, &ttyOrig, &ws);
if (childPid == -1) {
    perror("ptyFork");
    return 1;
}

if (childPid == 0) {
    /* === CHILD CODE === */
    /* stdin/stdout/stderr are now the PTY slave */
    /* Fall through to Step 3 */
} else {
    /* === PARENT CODE === */
    /* masterFd talks to the PTY master */
    /* Fall through to Steps 4 and 5 */
}
    

Step 3 – Child: exec a Shell on the PTY Slave

In the child process, the code simply launches a shell. The shell to use is read from the SHELL environment variable. If that variable is not set, /bin/sh is used as a safe fallback.

Because stdin/stdout/stderr are already connected to the PTY slave (done by ptyFork()), the shell will automatically read its input from the slave and write its output to the slave — no extra redirection needed.


/* Inside child (childPid == 0) */

char *shell = getenv("SHELL");
if (shell == NULL || *shell == '\0')
    shell = "/bin/sh";           /* fallback if SHELL not set */

execlp(shell, shell, (char *) NULL);
perror("execlp");   /* only reached if execlp fails */
    

Why execlp? execlp() searches PATH, so "bash" works without writing "/bin/bash". Passing the shell name as both arguments sets argv[0] correctly — programs use argv[0] to know their own name.

Step 4 – Parent: Open Script File and Enter Raw Mode

Back in the parent, two important things happen before the data relay loop starts:

4a. Open the typescript output file. If the user gave a filename as a command-line argument, that name is used; otherwise the file is called typescript. It is opened for writing (created fresh each time).


#include <fcntl.h>
#include <sys/stat.h>

int scriptFd;

/* Use argv[1] as filename if provided, else use "typescript" */
scriptFd = open((argc > 1) ? argv[1] : "typescript",
                O_WRONLY | O_CREAT | O_TRUNC,
                S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH);
if (scriptFd == -1) {
    perror("open typescript");
    return 1;
}
    

4b. Put the real terminal in raw mode. ttySetRaw() disables all special character processing on the parent’s terminal. This means keystrokes go straight to the PTY master without the real terminal driver modifying them first.


/* ttySetRaw() disables canonical mode, echo, signal chars on real terminal */
ttySetRaw(STDIN_FILENO, &ttyOrig);

/* Register cleanup: restore terminal on exit */
if (atexit(ttyReset) != 0) {
    perror("atexit");
    return 1;
}
    

Why raw mode? Without raw mode, the real terminal driver would process Ctrl+C, apply echo, do line buffering — all before the data even reaches the script process. That would mean the PTY slave also processes those characters, causing double-processing. Raw mode on the real terminal prevents this second round of interpretation.

Why atexit? If the program exits for any reason (error, EOF, signal caught by exit()), ttyReset() runs automatically and restores the terminal. Without this, the user’s terminal would be stuck in raw mode after script exits.

Step 5 – Parent: The Data Relay Loop using select()

This is the heart of the script program. The parent runs an infinite loop that watches two file descriptors simultaneously using select():

  • STDIN_FILENO — input from the user’s keyboard (real terminal)
  • masterFd — output from the shell (via PTY master)

Real Terminal (stdin) script parent
select() monitors both
PTY master (masterFd)
→ Shell input
↑ reverse path
Real Terminal (stdout)
+ typescript file
script parent PTY master (masterFd)
← Shell output

#include <sys/select.h>

fd_set  inFds;
char    buf[256];
ssize_t numRead;

for (;;) {
    FD_ZERO(&inFds);
    FD_SET(STDIN_FILENO, &inFds);   /* watch real terminal */
    FD_SET(masterFd,     &inFds);   /* watch PTY master    */

    /* Block until one of them has data */
    if (select(masterFd + 1, &inFds, NULL, NULL, NULL) == -1) {
        perror("select");
        break;
    }

    /* ---- Direction 1: keyboard → PTY master (→ shell) ---- */
    if (FD_ISSET(STDIN_FILENO, &inFds)) {
        numRead = read(STDIN_FILENO, buf, sizeof(buf));
        if (numRead <= 0)
            break;                          /* EOF or error */
        if (write(masterFd, buf, numRead) != numRead) {
            fprintf(stderr, "write to masterFd failed\n");
            break;
        }
    }

    /* ---- Direction 2: PTY master (shell output) → screen + file ---- */
    if (FD_ISSET(masterFd, &inFds)) {
        numRead = read(masterFd, buf, sizeof(buf));
        if (numRead <= 0)
            break;                          /* shell exited */

        /* Show output on real terminal */
        if (write(STDOUT_FILENO, buf, numRead) != numRead) {
            fprintf(stderr, "write to stdout failed\n");
            break;
        }
        /* Save output to typescript file */
        if (write(scriptFd, buf, numRead) != numRead) {
            fprintf(stderr, "write to scriptFd failed\n");
            break;
        }
    }
}
    

Loop exit conditions: The loop exits (and the program ends) when either the real terminal closes (user closes terminal) or the PTY master returns EOF/error (shell has exited). The atexit handler then runs and restores terminal settings.

Complete script.c Program Skeleton

Putting all five steps together into one clean skeleton so you can see the overall flow:


#include <sys/stat.h>
#include <fcntl.h>
#include <termios.h>
#include <sys/select.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "pty_fork.h"        /* ptyFork() */
#include "tty_functions.h"   /* ttySetRaw() */

#define BUF_SIZE  256
#define MAX_SNAME 1000

struct termios ttyOrig;

/* Step 4b helper: restore terminal on exit */
static void ttyReset(void)
{
    tcsetattr(STDIN_FILENO, TCSANOW, &ttyOrig);
}

int main(int argc, char *argv[])
{
    char    slaveName[MAX_SNAME];
    char   *shell;
    int     masterFd, scriptFd;
    struct  winsize ws;
    fd_set  inFds;
    char    buf[BUF_SIZE];
    ssize_t numRead;
    pid_t   childPid;

    /* --- Step 1: save real terminal state --- */
    tcgetattr(STDIN_FILENO, &ttyOrig);
    ioctl(STDIN_FILENO, TIOCGWINSZ, &ws);

    /* --- Step 2: fork with PTY --- */
    childPid = ptyFork(&masterFd, slaveName, MAX_SNAME, &ttyOrig, &ws);

    if (childPid == 0) {
        /* --- Step 3: child runs shell --- */
        shell = getenv("SHELL");
        if (shell == NULL || *shell == '\0')
            shell = "/bin/sh";
        execlp(shell, shell, (char *) NULL);
        perror("execlp");
        _exit(1);
    }

    /* --- Step 4a: open output file --- */
    scriptFd = open((argc > 1) ? argv[1] : "typescript",
                    O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (scriptFd == -1) { perror("open"); return 1; }

    /* --- Step 4b: raw mode + cleanup handler --- */
    ttySetRaw(STDIN_FILENO, &ttyOrig);
    atexit(ttyReset);

    /* --- Step 5: relay loop --- */
    for (;;) {
        FD_ZERO(&inFds);
        FD_SET(STDIN_FILENO, &inFds);
        FD_SET(masterFd, &inFds);

        if (select(masterFd + 1, &inFds, NULL, NULL, NULL) == -1)
            break;

        if (FD_ISSET(STDIN_FILENO, &inFds)) {
            numRead = read(STDIN_FILENO, buf, BUF_SIZE);
            if (numRead <= 0) break;
            write(masterFd, buf, numRead);
        }

        if (FD_ISSET(masterFd, &inFds)) {
            numRead = read(masterFd, buf, BUF_SIZE);
            if (numRead <= 0) break;
            write(STDOUT_FILENO, buf, numRead);
            write(scriptFd, buf, numRead);
        }
    }

    return 0;
}
    

What the Running Program Looks Like

Here is a typical shell session showing what happens when you run the script program:


$ tty
/dev/pts/1          <-- real terminal (login shell)

$ echo $$
7979                <-- PID of login shell

$ ./script          <-- start recording

$ tty
/dev/pts/24         <-- PTY slave (new shell inside script)

$ echo $$
29825               <-- PID of subshell started by script

$ ps -p 7979 -p 29825 -C script -o "pid ppid sid tty cmd"
  PID  PPID   SID TT      CMD
 7979  7972  7979 pts/1   /bin/bash      (login shell)
29824  7979  7979 pts/1   ./script       (script itself)
29825 29824 29825 pts/24  /bin/bash      (subshell on PTY slave)

$ exit              <-- stop recording

$ cat typescript    <-- view recorded session
$ tty
/dev/pts/24
...
    

Notice: ./script (PID 29824) runs on pts/1 (the real terminal). Its child shell (PID 29825) runs on pts/24 (the PTY slave). Everything typed in that subshell session ends up in the typescript file.

Interview Questions – script Program & PTY Data Relay

Q1. What is the purpose of the script command and how does it use a PTY internally?

The script command records all terminal input and output to a file. Internally it creates a PTY pair using ptyFork(). The parent holds the master fd and relays data in both directions; the child (a shell) sees the PTY slave as its terminal. All data flowing through is copied to a file.

Q2. Why does the script parent put the real terminal in raw mode?

The real terminal would otherwise process special characters (Ctrl+C, echo, line buffering) before passing them to the script process. Since the PTY slave also processes terminal special characters, this would cause double-processing. Raw mode on the real terminal ensures that each character is delivered exactly once to the line discipline — on the slave side only.

Q3. What happens to the terminal if script crashes without restoring settings?

The terminal stays in raw mode. The user sees no echo, line editing breaks, and Ctrl+C may not work. The fix is to use atexit(ttyReset) so the original settings are always restored no matter how the program exits.

Q4. Why is select() used instead of two separate threads for reading from stdin and masterFd?

select() lets a single thread watch multiple file descriptors simultaneously without blocking on either one. Using threads would add complexity, synchronization requirements, and race conditions. select() is simpler and well-suited since both sources produce data asynchronously but the processing is sequential.

Q5. What does ptyFork() do internally at a high level?

ptyFork() opens a PTY master device, forks a child process, and in the child: opens the corresponding slave device, sets it as the controlling terminal, and redirects stdin/stdout/stderr to the slave fd. It also applies the provided termios and winsize structures to the slave.

Q6. How does the shell running inside script know which terminal to use?

Because ptyFork() sets the PTY slave as the child’s controlling terminal and connects all standard streams to it. When the child calls execlp(shell, ...), the new shell inherits those file descriptors and treats the PTY slave as its terminal — it never knows it’s not a real terminal.

Q7. What determines which shell is started by script?

The SHELL environment variable. If it is set and non-empty, that value is used with execlp(). If it is unset or empty, /bin/sh is used as a fallback.

Q8. When does the script relay loop terminate?

The loop exits when either read() from STDIN_FILENO returns 0 or an error (user closed terminal / typed EOF) or read() from masterFd returns 0 or an error (the child shell has exited and the PTY slave side is closed).

Next: Terminal Attributes & Window Size

Learn how the PTY master and slave share terminal settings and how to handle window resize events.

Part 2 → EmbeddedPathashala.com

Leave a Reply

Your email address will not be published. Required fields are marked *