script is a standard Unix utility that records everything that happens in a terminal session โ every command you type and every output produced โ and saves it to a file called typescript.
When you run script, it starts a new shell. Everything you do in that shell is recorded. When you type exit, the recording stops and you get a full transcript of the session.
script is historically important: many of the shell sessions shown in the TLPI book were captured using it. Understanding how script works is one of the best practical exercises for mastering pseudoterminals.
$ script # Start recording to ./typescript
Script started, file is typescript
$ ls /tmp
file1 file2 file3
$ echo "hello"
hello
$ exit # Stop recording
Script done, file is typescript
$ cat typescript # View the recording
Script started...
$ ls /tmp
file1 file2 file3
...
In a normal login session, the shell is connected directly to the user’s real terminal. When script runs, it inserts itself in between the user and the shell using a PTY pair.
| ๐ค User at terminal |
โ โ |
๐บ Real Terminal (stdin/stdout) |
โ โ |
๐ script (parent) proxy |
โ write |
PTY MASTER |
KERNEL PTY |
PTY SLAVE (stdin/out/err) |
โ โ |
๐ bash (child) |
| โ read |
||||||||||
| script also writes everything read from master to typescript file | ||||||||||
The script process acts as a proxy:
- It reads from the real terminal (user’s keyboard input) and writes it to the PTY master โ shell reads it from slave.
- It reads from the PTY master (shell’s output, echoed input) and writes it to the real terminal โ user sees it on screen.
- It also copies everything read from master into the typescript file.
You might wonder: the typescript file records both output and the commands you typed. How?
The answer is terminal echo. Recall from Part 1:
- script writes your keypress to the PTY master.
- The kernel’s PTY driver copies it to the slave input queue.
- Because echo is ON, the kernel also copies the same byte to the slave output queue.
- script reads this echo back from the PTY master.
- script writes this echoed byte to both the screen AND the typescript file.
When echo is disabled (e.g., for password entry), the input is NOT echoed to the output queue, so passwords do not appear in the typescript file. This is correct and intentional behaviour.
When script starts, it changes the real terminal (the one the user is sitting at) to raw mode. Here is why:
- Real terminal processes Ctrl-C itself โ sends SIGINT to script
- Real terminal does line editing (backspace, Ctrl-U)
- Double line buffering: real terminal AND PTY slave both buffer
- Shell gets confused, double-echo, broken behaviour
- Real terminal passes every byte directly to script unchanged
- Ctrl-C goes through PTY to shell, which handles it correctly
- No double buffering
- All line editing happens inside the PTY slave (shell’s tty)
#include <termios.h>
#include <unistd.h>
/* Save original terminal settings */
struct termios orig_tios;
tcgetattr(STDIN_FILENO, &orig_tios);
/* Copy and modify for raw mode */
struct termios raw = orig_tios;
raw.c_lflag &= ~(ICANON | ISIG | IEXTEN | ECHO);
raw.c_iflag &= ~(BRKINT | ICRNL | IGNBRK | IGNCR | INLCR
| INPCK | ISTRIP | IXON | PARMRK);
raw.c_oflag &= ~OPOST;
raw.c_cc[VMIN] = 1;
raw.c_cc[VTIME] = 0;
/* Apply raw mode to real terminal */
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
/* ... run script recording loop ... */
/* Restore original settings when script exits */
tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_tios);
Below is a simplified but complete implementation showing how the main relay loop works. Error handling is simplified for clarity.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <termios.h>
#include <sys/select.h>
#include <sys/wait.h>
#include "pty_fork.h" /* ptyFork() from TLPI */
#define SCRIPT_FILE "typescript"
#define BUF_SIZE 256
int main(void)
{
int masterFd, scriptFd;
pid_t childPid;
char slaveName[64];
char buf[BUF_SIZE];
int nread;
struct termios orig_tios, raw_tios;
fd_set readfds;
/* --- Open typescript output file --- */
scriptFd = open(SCRIPT_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (scriptFd == -1) { perror("open typescript"); exit(1); }
/* --- Get current terminal settings and window size --- */
struct termios tios;
struct winsize ws;
tcgetattr(STDIN_FILENO, &tios);
ioctl(STDIN_FILENO, TIOCGWINSZ, &ws);
tcgetattr(STDIN_FILENO, &orig_tios); /* save for restore later */
/* --- Fork child with PTY, passing real terminal config --- */
childPid = ptyFork(&masterFd, slaveName, sizeof(slaveName),
&tios, &ws);
if (childPid == -1) { perror("ptyFork"); exit(1); }
if (childPid == 0) {
/* ======= CHILD: run a shell ======= */
execlp("bash", "bash", NULL);
perror("execlp bash");
exit(1);
}
/* ======= PARENT: the script process ======= */
printf("Script started, file is %s\n", SCRIPT_FILE);
/* Put real terminal in raw mode so every byte passes through unchanged */
raw_tios = orig_tios;
raw_tios.c_lflag &= ~(ICANON | ISIG | IEXTEN | ECHO);
raw_tios.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
raw_tios.c_oflag &= ~OPOST;
raw_tios.c_cc[VMIN] = 1;
raw_tios.c_cc[VTIME] = 0;
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw_tios);
/* === Main relay loop === */
while (1) {
FD_ZERO(&readfds);
FD_SET(STDIN_FILENO, &readfds); /* real terminal (user input) */
FD_SET(masterFd, &readfds); /* PTY master (shell output) */
if (select(masterFd + 1, &readfds, NULL, NULL, NULL) == -1)
break;
/* --- User typed something on real terminal --- */
if (FD_ISSET(STDIN_FILENO, &readfds)) {
nread = read(STDIN_FILENO, buf, BUF_SIZE);
if (nread <= 0) break;
/* Relay to PTY master: shell will see it as keyboard input */
write(masterFd, buf, nread);
}
/* --- Shell produced output (or echoed input) --- */
if (FD_ISSET(masterFd, &readfds)) {
nread = read(masterFd, buf, BUF_SIZE);
if (nread <= 0) break; /* shell exited */
/* Write to real terminal (user sees it) */
write(STDOUT_FILENO, buf, nread);
/* ALSO write to typescript file (this is the recording) */
write(scriptFd, buf, nread);
}
}
/* === Cleanup === */
/* Restore real terminal to original settings */
tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_tios);
/* Wait for child (shell) to finish */
waitpid(childPid, NULL, 0);
close(masterFd);
close(scriptFd);
printf("\nScript done, file is %s\n", SCRIPT_FILE);
return 0;
}
| Event | Source | script reads from | script writes to | Goes to typescript? |
|---|---|---|---|---|
| User types a character | Real terminal (stdin) | STDIN_FILENO | masterFd (PTY) | โ (not directly) |
| PTY echoes typed char | PTY slave echo | masterFd | STDOUT + typescript | โ YES |
| Shell prints output | Shell writes to slave | masterFd | STDOUT + typescript | โ YES |
| User types password (echo off) | Real terminal (stdin) | STDIN_FILENO | masterFd only | โ NO (no echo) |
| Shell exits | PTY master | masterFd returns EIO/0 | Loop exits, cleanup | โ |
This is one of the most important design points of the PTY system. When a program like sudo or ssh asks for a password:
- It calls
tcsetattr()to disable echo on the slave tty (clears the ECHO flag inc_lflag). - The user types the password โ script relays it to masterFd โ kernel puts it in slave input queue.
- Because echo is OFF, the kernel does NOT copy those bytes to the slave output queue.
- script reads from masterFd โ sees nothing (no echo output).
- Nothing is written to the typescript file.
TLPI’s Figure 64-4 shows the layers of the script architecture. Let’s walk through each connection:
| Connection in Figure | What it represents |
|---|---|
| User terminal โ script process | The real terminal’s stdin/stdout. script reads keypresses from here and sends shell output back to here for display. |
| script process โ PTY master | script reads all output (data + echoed input) from the master, and writes user input to the master to relay to shell. |
| PTY master โ PTY slave (kernel) | Kernel PTY driver. Data flows bidirectionally with terminal line discipline applied on slave side. |
| PTY slave โ shell (child) | Shell’s stdin/stdout/stderr are all the PTY slave. Shell reads user commands from here and prints results to here. |
| script โ typescript file | Everything script reads from the PTY master is copied to this file. This is the recording. |
| fork() + exec() arrow | script calls ptyFork() which internally forks. The child execs bash. The parent becomes the script proxy. |
To build and test (assuming you have TLPI source available for ptyFork):
# Using the TLPI library:
gcc -o myscript myscript.c pty_fork.c error_functions.c -I../lib
# Or implement ptyFork inline and compile standalone:
gcc -o myscript myscript_standalone.c
# Run it:
./myscript
# You'll get a new bash shell.
# Type some commands:
echo "hello from script"
ls /tmp
# Exit the shell:
exit
# Check the recording:
cat typescript
A production script implementation also needs to handle SIGWINCH: the signal sent when the terminal window is resized.
#include <signal.h>
#include <sys/ioctl.h>
static int masterFdGlobal; /* for use in signal handler */
/* Called when user resizes the terminal window */
void sigwinch_handler(int sig)
{
struct winsize ws;
/* Get the new size from the real terminal */
if (ioctl(STDIN_FILENO, TIOCGWINSZ, &ws) == -1)
return;
/* Apply the new size to the PTY slave via the master */
if (ioctl(masterFdGlobal, TIOCSWINSZ, &ws) == -1)
return;
/* The kernel will also send SIGWINCH to the shell's process group */
}
/* In main: */
masterFdGlobal = masterFd;
signal(SIGWINCH, sigwinch_handler);
Without this handler, programs like vim and less running inside the script session will not reflow their display when you resize the window.
| Part | Topic | Key Concept |
|---|---|---|
| Part 1 | PTY I/O basics | Master/slave pair; 4kB buffer; close behaviour (SIGHUP/EIO); canonical mode echo |
| Part 2 | ptyFork() | posix_openpt + fork + setsid + dup2; child gets slave as stdin/out/err |
| Part 3 | Packet Mode | TIOCPKT; control byte protocol; POLLPRI/exceptfds; flow control for telnet/rlogin |
| Part 4 | script(1) program | Proxy pattern; raw mode on real terminal; record via master output; passwords not captured |
