Why Most Programs Don’t Care About Job Control
When you press Control-Z in a terminal, the kernel sends SIGTSTP to every process in the foreground process group. For the vast majority of programs โ ls, grep, gcc โ the default action (stop the process) is exactly what you want. The shell then sends SIGCONT when you type fg or bg, and the program resumes as if nothing happened.
But some programs own the terminal display: vi, less, htop, ncurses-based apps. These programs set the terminal into raw/non-canonical mode, move the cursor around, and paint the whole screen. If one of these is simply stopped mid-flight, the terminal is left in an unknown state โ characters won’t echo, the cursor is in a random position, and the screen looks broken.
These programs must handle SIGTSTP to clean up before stopping, and handle SIGCONT to restore their display when resumed.
This is the central subtlety of SIGTSTP handling. If you write a handler for SIGTSTP and it simply returns, the process is NOT stopped. Unlike SIGSTOP (which can never be caught), catching SIGTSTP completely overrides its default stop behavior. Your handler runs and then your program continues running โ which is the opposite of what you wanted.
SIGSTOP inside the SIGTSTP handler. This works in practice (SIGSTOP cannot be caught), but it misleads the parent process (the shell). When the shell does wait() or receives SIGCHLD, it sees the child was stopped by SIGSTOP, not SIGTSTP. This breaks the shell’s internal job-control accounting.SIG_DFL so it actually stops the process. This way the kernel sees the stop as caused by SIGTSTP โ which is exactly what the shell expects.Here is the precise sequence your SIGTSTP handler must follow:
Reset terminal to canonical (line-at-a-time) mode. Move cursor to bottom-left. Restore any custom terminal settings. Print any “suspend” notification if needed.
Call
signal(SIGTSTP, SIG_DFL). Now SIGTSTP will perform its default action (stop the process) when raised.Call
raise(SIGTSTP). Since SIGTSTP is currently blocked (we’re inside its handler and SA_NODEFER was not set), the signal becomes pending.The pending SIGTSTP is immediately delivered. Since its disposition is now SIG_DFL, the process is stopped. The process suspends here. When SIGCONT arrives later, execution resumes from this exact line.
Reblock SIGTSTP (restore previous mask), reestablish your handler with
sigaction(), restore terminal settings, redraw the screen.| User presses Control-Z |
| โ |
| Kernel sends SIGTSTP to foreground process group |
| โ |
| Your handler runs โ Step 1: clean terminal |
| โ |
| Step 2: signal(SIGTSTP, SIG_DFL) โ Step 3: raise(SIGTSTP) |
| โ |
| Step 4: sigprocmask(SIG_UNBLOCK, SIGTSTP) โ Process STOPS here |
| โ (later, after fg/bg + SIGCONT) |
| Execution resumes after sigprocmask() |
| โ |
| Step 5: reblock SIGTSTP, reestablish handler, restore terminal, redraw screen |
This is the canonical implementation from TLPI. The handler follows all 5 steps correctly. The main program only installs the handler if SIGTSTP is not already being ignored โ this respects non-job-control shells.
/* handling_SIGTSTP.c
* Demonstrates the correct 5-step pattern for handling SIGTSTP
* Compile: gcc -o handling_SIGTSTP handling_SIGTSTP.c
* Run: ./handling_SIGTSTP
* Test: Press Control-Z to suspend, then 'fg' to resume
*/
#include <signal.h>
#include <stdio.h>
#include <errno.h>
#include <unistd.h>
/* ---------------------------------------------------------------
* SIGTSTP handler: the 5-step correct pattern
* --------------------------------------------------------------- */
static void tstpHandler(int sig)
{
sigset_t tstpMask, prevMask;
int savedErrno;
struct sigaction sa;
/* Save errno - signal handlers can modify it */
savedErrno = errno;
/* STEP 1: Clean up terminal (in a real screen program you would
* restore canonical mode, move cursor, etc.)
* Here we just print a message. */
printf("Caught SIGTSTP - cleaning up terminal\n");
fflush(stdout);
/* STEP 2: Reset SIGTSTP disposition to default (SIG_DFL)
* This is necessary so that when we raise SIGTSTP below
* (after unblocking), it actually STOPS the process. */
if (signal(SIGTSTP, SIG_DFL) == SIG_ERR) {
perror("signal");
return;
}
/* STEP 3: Raise a fresh SIGTSTP.
* Because SIGTSTP is currently blocked (we are inside its handler
* and SA_NODEFER was not set), this signal becomes PENDING.
* It will not be delivered yet. */
raise(SIGTSTP);
/* STEP 4: Unblock SIGTSTP.
* The pending SIGTSTP from step 3 is immediately delivered.
* Since its disposition is now SIG_DFL, the process is STOPPED.
* *** Execution suspends at this exact point ***
* When 'fg' or 'bg' is typed and SIGCONT arrives, execution
* resumes from the next line below. */
sigemptyset(&tstpMask);
sigaddset(&tstpMask, SIGTSTP);
if (sigprocmask(SIG_UNBLOCK, &tstpMask, &prevMask) == -1) {
perror("sigprocmask");
return;
}
/* STEP 5a: We resumed (SIGCONT was received).
* Restore the previous signal mask (reblock SIGTSTP).
* This prevents recursive handler invocation. */
if (sigprocmask(SIG_SETMASK, &prevMask, NULL) == -1) {
perror("sigprocmask");
return;
}
/* STEP 5b: Reestablish this handler for future SIGTSTP signals */
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sa.sa_handler = tstpHandler;
if (sigaction(SIGTSTP, &sa, NULL) == -1) {
perror("sigaction");
return;
}
/* STEP 5c: Restore terminal settings and redraw screen
* (In a real screen program: set terminal back to raw mode,
* check window size, redraw contents.) */
printf("Exiting SIGTSTP handler - resuming normally\n");
fflush(stdout);
errno = savedErrno;
}
int main(void)
{
struct sigaction sa;
/* IMPORTANT: Only install handler if SIGTSTP is not already ignored.
* If a non-job-control shell sets SIGTSTP to SIG_IGN, we must
* not override that โ the process should remain immune. */
if (sigaction(SIGTSTP, NULL, &sa) == -1) {
perror("sigaction");
return 1;
}
if (sa.sa_handler != SIG_IGN) {
/* SIGTSTP is not ignored - safe to install our handler */
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sa.sa_handler = tstpHandler;
if (sigaction(SIGTSTP, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
printf("SIGTSTP handler installed. Press Control-Z to test.\n");
} else {
printf("SIGTSTP is ignored (non-job-control shell). No handler.\n");
}
/* Main loop - just wait for signals */
for (;;) {
pause();
printf("Main: returned from pause()\n");
fflush(stdout);
}
return 0;
}
/* Expected output:
* $ ./handling_SIGTSTP
* SIGTSTP handler installed. Press Control-Z to test.
* ^Z
* Caught SIGTSTP - cleaning up terminal
* [1]+ Stopped ./handling_SIGTSTP
* $ fg
* ./handling_SIGTSTP
* Exiting SIGTSTP handler - resuming normally
* Main: returned from pause()
* ^C
*/
This example simulates a simplified screen-handling application like less or htop. It sets the terminal to raw mode, handles SIGTSTP to restore canonical mode before stopping, and restores raw mode when resumed via SIGCONT.
/* screen_app.c
* Simulates a screen-handling app (like less/vi) with proper
* SIGTSTP and SIGCONT handling and terminal mode management.
* Compile: gcc -o screen_app screen_app.c
* Run: ./screen_app
* Test: Press Control-Z, then fg; also Press q to quit
*/
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <termios.h>
#include <string.h>
#include <errno.h>
/* Global: saved terminal settings so we can restore them */
static struct termios g_saved_termios;
static int g_raw_mode = 0;
/* -----------------------------------------------------------
* Terminal helpers
* ----------------------------------------------------------- */
static void enter_raw_mode(void)
{
struct termios raw;
if (tcgetattr(STDIN_FILENO, &g_saved_termios) == -1) return;
raw = g_saved_termios;
/* Disable canonical mode and echo */
raw.c_lflag &= ~(ICANON | ECHO);
raw.c_cc[VMIN] = 1;
raw.c_cc[VTIME] = 0;
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) return;
g_raw_mode = 1;
printf("[Terminal: raw mode ON]\n");
fflush(stdout);
}
static void restore_terminal(void)
{
if (g_raw_mode) {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_saved_termios);
g_raw_mode = 0;
printf("[Terminal: canonical mode restored]\n");
fflush(stdout);
}
}
static void draw_screen(void)
{
/* In a real app: redraw the full display */
printf("[Screen redrawn - press q to quit, Control-Z to suspend]\n");
fflush(stdout);
}
/* -----------------------------------------------------------
* SIGTSTP handler - clean up then stop
* ----------------------------------------------------------- */
static void sigtstp_handler(int sig)
{
sigset_t tstpMask, prevMask;
int savedErrno = errno;
struct sigaction sa;
/* Step 1: Restore terminal to sane state */
restore_terminal();
printf("[Suspending: terminal restored to canonical mode]\n");
fflush(stdout);
/* Step 2: Reset SIGTSTP to default */
signal(SIGTSTP, SIG_DFL);
/* Step 3: Raise SIGTSTP (becomes pending because it's blocked) */
raise(SIGTSTP);
/* Step 4: Unblock - process stops here until SIGCONT */
sigemptyset(&tstpMask);
sigaddset(&tstpMask, SIGTSTP);
sigprocmask(SIG_UNBLOCK, &tstpMask, &prevMask);
/* -- Process is STOPPED here -- */
/* -- Execution resumes here when SIGCONT arrives -- */
/* Step 5a: Reblock SIGTSTP */
sigprocmask(SIG_SETMASK, &prevMask, NULL);
/* Step 5b: Reestablish handler */
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sa.sa_handler = sigtstp_handler;
sigaction(SIGTSTP, &sa, NULL);
/* Step 5c: Restore raw mode and redraw */
enter_raw_mode();
draw_screen();
errno = savedErrno;
}
/* -----------------------------------------------------------
* SIGCONT handler (optional - for notification only)
* The actual restoration is done at the tail of sigtstp_handler
* ----------------------------------------------------------- */
static void sigcont_handler(int sig)
{
/* Just a notification. Real apps may set a flag here
* to trigger a screen resize check. */
(void)sig;
}
/* -----------------------------------------------------------
* main
* ----------------------------------------------------------- */
int main(void)
{
struct sigaction sa;
printf("Screen-Handling App Demo\n");
printf("Commands: q=quit, Control-Z=suspend\n\n");
/* Install SIGTSTP handler only if not already ignored */
if (sigaction(SIGTSTP, NULL, &sa) == -1) { perror("sigaction"); return 1; }
if (sa.sa_handler != SIG_IGN) {
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sa.sa_handler = sigtstp_handler;
if (sigaction(SIGTSTP, &sa, NULL) == -1) { perror("sigaction"); return 1; }
}
/* Install SIGCONT handler only if not already ignored */
if (sigaction(SIGCONT, NULL, &sa) == -1) { perror("sigaction"); return 1; }
if (sa.sa_handler != SIG_IGN) {
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sa.sa_handler = sigcont_handler;
if (sigaction(SIGCONT, &sa, NULL) == -1) { perror("sigaction"); return 1; }
}
/* Set terminal to raw mode (like vi/less) */
enter_raw_mode();
draw_screen();
/* Event loop: read one char at a time (possible only in raw mode) */
while (1) {
char c;
ssize_t n = read(STDIN_FILENO, &c, 1);
if (n == -1) {
if (errno == EINTR) continue; /* Interrupted by signal */
break;
}
if (c == 'q' || c == 'Q') break;
printf("[Key pressed: 0x%02X]\n", (unsigned char)c);
fflush(stdout);
}
restore_terminal();
printf("\nBye!\n");
return 0;
}
/* Session example:
* $ ./screen_app
* [Terminal: raw mode ON]
* [Screen redrawn - press q to quit, Control-Z to suspend]
* ^Z
* [Terminal: canonical mode restored]
* [Suspending: terminal restored to canonical mode]
* [1]+ Stopped ./screen_app
* $ fg
* [Terminal: raw mode ON]
* [Screen redrawn - press q to quit, Control-Z to suspend]
* q
* [Terminal: canonical mode restored]
* Bye!
*/
nohup has SIGHUP set to SIG_IGN. Overriding SIG_IGN would re-expose the process to signals it was deliberately shielded from.After reestablishing the handler in Step 5b, there is a tiny window before the handler returns. If another SIGTSTP arrives at this moment, the handler would be called recursively. Recursive signal handler calls can cause stack overflow if a rapid stream of SIGTSTP signals arrives.
Keeping SIGTSTP blocked during Steps 5b and 5c (and unblocking only when the handler returns via the kernel’s automatic unblocking) prevents this race.
| Signal | Must Check SIG_IGN? | Reason |
|---|---|---|
| SIGTSTP | Yes | Non-job-control shells set it to SIG_IGN |
| SIGTTIN | Yes | Same: non-job-control shell |
| SIGTTOU | Yes | Same: non-job-control shell |
| SIGINT | Yes | Background processes under non-job-control shells have it SIG_IGN |
| SIGQUIT | Yes | Same as SIGINT for background jobs |
| SIGHUP | Yes | nohup(1) sets it to SIG_IGN to protect the command |
Interview Questions
Q1: Why does catching SIGTSTP not stop a process?
When you install a signal handler, the handler function runs instead of the default action. For SIGTSTP, the default action is to stop the process. By catching it, you completely replace that behavior โ the handler runs and returns, and the process continues. To actually stop the process, the handler must explicitly re-raise SIGTSTP with SIG_DFL disposition, or raise SIGSTOP (though SIGSTOP is less correct).
Q2: Why should SIGSTOP not be raised in the SIGTSTP handler, even though it works?
SIGSTOP cannot be caught, blocked, or ignored, so raising it does stop the process. However, when the shell does waitpid() with WUNTRACED and inspects the stop cause, it sees SIGSTOP โ not SIGTSTP. This breaks shell job-control accounting. The correct approach is to reset SIGTSTP to SIG_DFL and raise SIGTSTP, so the kernel records the stop reason as SIGTSTP.
Q3: What is the purpose of the SIG_UNBLOCK call in the SIGTSTP handler?
When a signal handler is executing, the signal that triggered it is automatically blocked (unless SA_NODEFER is set). So inside the SIGTSTP handler, SIGTSTP is blocked. After resetting its disposition to SIG_DFL and raising it, the raised SIGTSTP is pending (blocked). The sigprocmask(SIG_UNBLOCK) call delivers this pending signal, which stops the process. Without unblocking, the process would never actually stop.
Q4: Why must the SIGTSTP handler check if the signal is already SIG_IGN before installing a handler?
A non-job-control shell (like the traditional Bourne shell) sets SIGTSTP, SIGTTIN, and SIGTTOU to SIG_IGN for processes it starts. This signals to the program that job control is not available. If the program overrides SIG_IGN, it breaks this contract and may behave incorrectly. Similarly, SIGHUP is SIG_IGN for commands run under nohup(1). The rule is: never install a handler if the signal is already ignored.
Q5: What is the race condition if SIGTSTP is unblocked before the handler reestablishes itself?
If SIGTSTP is unblocked (Step 4) and the process resumes, but the handler is not yet reestablished (Step 5b), there is a window where SIGTSTP has SIG_DFL disposition. If Control-Z is pressed during this window, the process stops permanently with no cleanup. The correct order is: reblock SIGTSTP first, then reestablish the handler, then unblocking happens automatically when the handler returns.
Q6: What should a screen-handling program do in its SIGTSTP handler regarding the terminal?
Before suspending, it should: restore the terminal to canonical (line-at-a-time) mode, turn echo back on, move the cursor to the bottom-left corner, and flush any output. When resumed (after SIGCONT via the tail of the SIGTSTP handler), it should: put the terminal back into raw/non-canonical mode, check if the terminal window size changed (SIGWINCH), and fully redraw the display.
