SIGHUP Signal for Configuration Reloads
SIGHUP is a signal caught between two worlds. It was born from the physical "hang up" of terminal lines, and its original meaning—the loss of a controlling terminal—still applies.
Many applications, including Go applications by default, respond to three signals for termination: SIGTERM
, SIGINT
, and SIGHUP
. Among these, SIGHUP
carries some extra nuances and may be less relevant today.
By definition:
SIGHUP ("signal hang up") is a signal sent to a process when its controlling terminal is closed. – Wikipedia
But what exactly is a "controlling terminal"?
Terminal
Suppose you open a terminal application. This is the window where you see text and type commands. It might be iTerm2, Terminal.app on macOS, or GNOME Terminal on Linux. This window is a graphical interface, but inside it, another program is actually running your commands. That program is usually a shell like bash or zsh.
These two programs need a way to communicate:
The terminal window needs to send your typed keys to the shell.
The shell needs to send its output (like the result of the
ls
command) back to the window to be displayed.
They communicate using something called a pseudo-terminal (PTY), which is a virtual device. Behind the scenes, your terminal app asks the operating system: "Please give me a new virtual terminal device" by opening a special file called /dev/ptmx
. The kernel receives this request and creates a pair of communication endpoints:
The master side (used by the terminal app), represented by a master file descriptor.
The slave side (used by the shell), represented by a device path such as `/dev/pts/2`. The shell opens this path to get its slave file descriptor.
Whatever you type into the terminal window (like ls
, zsh
, etc.) is written into /dev/pts/2
. Any program output written to stdout
also goes into /dev/pts/2
. The terminal app (such as iTerm2) reads from there and draws the result on your screen.
The number 2
in /dev/pts/2
is just an identifier. If you open another terminal window, the system might create a new path like /dev/pts/3
. Open another, and it could be /dev/pts/4
, and so on. Each terminal session typically gets its own unique /dev/pts/
path.
Controlling Terminal
When a terminal window is opened, it does not magically run bash
in the background. Instead, it forks a child process. This child process calls the setsid()
system call. That call does two main things:
Creates a new session.
Makes the process the session leader, since it is the first process in the session.
So, what is a session?
When you use a shell in a terminal, you can run multiple commands at the same time. You might start a command, put it in the background (using &
), or suspend it (using Ctrl+Z
). Then you can start another one. The shell needs a way to keep track of these related commands. Each of these commands might include several processes, like in a pipeline: ls | grep foo | sort
. The session is the container that holds all these related commands, or jobs, that you started from a single terminal login.
These commands usually need to interact with you. They need to read your input and display their results. That is where the controlling terminal comes in. The slave side of the pseudo-terminal (PTY) pair (like /dev/pts/2
) acts as the controlling terminal.
This is how it works: when you type ls
and press Enter, the ls
command (which runs as part of the session) receives the input from the controlling terminal. It reads your command from standard input, and its output is sent back to the controlling terminal, where your terminal window displays it.
The controlling terminal has two main roles:
Providing I/O channels: It acts as the standard input, output, and error for processes in the session. This has already been discussed earlier.
Job control: It helps manage signals from the keyboard and keeps track of process group states. This includes:
Sending signals when you press certain key combinations (
Ctrl+C
forSIGINT
,Ctrl+Z
forSIGTSTP
, etc.).Keeping track of which process group is in the foreground (which is the only one allowed to read from the terminal).
SIGHUP
When you click the "X" button to close a terminal window, the terminal emulator receives a system event that tells it to shut down.
The emulator begins its shutdown process by making a close()
system call to the kernel. It passes the file descriptor that represents its connection to the master end of the pseudo-terminal.
The kernel looks up the internal file object tied to that master PTY file descriptor and decreases its reference count. When the count reaches zero (which means the terminal emulator was the last process holding it open), the kernel sees that no one else is using the master side of the PTY:
Once the master side of the PTY is closed, the kernel realizes that the terminal device (for example, /dev/pts/2
) is no longer connected. The TTY subsystem in the kernel then sends a SIGHUP
signal to the processes that were using that terminal. This signal goes to the session leader, which is typically your shell (e.g. bash
, zsh
).
By default, the interactive shell forwards the SIGHUP
signal to all its child processes. This includes both foreground and background jobs, except for those that are explicitly detached, such as ones started with nohup
.
You can test this behavior with the following Go snippet:
func main() {
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGHUP)
fmt.Println("Process started (PID:", os.Getpid(), "). Waiting for SIGHUP...")
fmt.Println("You can send SIGHUP using: kill -HUP", os.Getpid())
fmt.Println("Or, run this in a terminal and then close the terminal window.")
go func() {
sig := <-signalChan
fmt.Printf("Received signal: %v\n", sig)
fmt.Println("Creating file: sighup_received.txt")
file, err := os.Create("sighup_received.txt")
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating file: %v\n", err)
os.Exit(1)
}
file.WriteString("SIGHUP signal received at: " + time.Now().String() + "\n")
file.Close()
fmt.Println("File created. Exiting.")
}()
select{}
}
Run this with go run .
. The program will wait for a SIGHUP
signal. You can trigger it by running kill -HUP <pid>
or by simply closing the terminal window where the program is running. It then creates a file and writes the current time to it.
SIGHUP & Reloading Configuration
Originally, SIGHUP
was used to notify a process that its controlling terminal had been closed, such as a device like /dev/pts/X
. This made sense during the time of physical terminals and serial lines, where disconnecting a session would lead the system to send SIGHUP
to all processes attached to that terminal.
As systems evolved, especially with the rise of daemon processes that run in the background without any terminal, the original purpose of SIGHUP
became less relevant.
Daemons are designed to run independently of user terminals. They do not receive keyboard signals like SIGINT
(from Ctrl+C) or SIGTSTP
(from Ctrl+Z). Although they still receive signals from the kernel or from other processes, they are not affected by terminal-based input.
Because of this, developers started repurposing existing signals for new use cases. SIGHUP
became an unofficial signal used to tell a daemon to reload its configuration. This convention allowed administrators to send a SIGHUP
signal to prompt a config reload, without restarting the process. It is now a common practice across many Unix-like systems.
For example, VictoriaMetrics, a popular open-source monitoring system, listens for SIGHUP
but does not take immediate action. Instead, it captures the signal to prevent the Go application from exiting. Then, it uses a separate channel to handle future SIGHUP
events, which can trigger configuration reloads:
func WaitForSigterm() os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
for {
sig := <-ch
if sig == syscall.SIGHUP {
// Prevent the program from stopping on SIGHUP
continue
}
signal.Stop(ch)
return sig
}
}
// NewSighupChan returns a channel, which is triggered on every SIGHUP.
func NewSighupChan() <-chan os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGHUP)
return ch
}
Wrapping up
SIGHUP
is a signal caught between two worlds. It was born from the physical "hang up" of terminal lines, and its original meaning—the loss of a controlling terminal—still applies. It can abruptly kill background tasks when an SSH session drops or a terminal window is closed.
At the same time, SIGHUP
has taken on a second role. It has become the standard way to tell daemons to reload their configuration without restarting. This isn't part of any formal standard—it's a convention that works well. Instead of defining a new signal, developers reused an existing one. The result is a signal with two lives: one rooted in disconnection and the other in control.
It's also a reminder of Unix's layered past. Its original purpose fades, but its modern role is essential. It's embedded in how services are managed today. Developers have to deal with this split identity. You can either detach your processes from the terminal using tools like nohup
, screen
, or tmux
, or daemonize them properly to avoid unexpected shutdowns. Or you can catch SIGHUP
directly and decide how to react—ignore it, shut down cleanly, or reload configuration.
Hi @FUNC25
Thank you for a great post.
Just wanted to clarify that
> By default, the shell forwards the SIGHUP signal to all its child processes.
is true only for interactive shells, according to [1], so scripted shells will not inherit this behavior.
[1] - https://www.gnu.org/software/bash/manual/html_node/Signals.html#:~:text=Before%20exiting%2C%20an%20interactive%20shell%20resends%20the%20SIGHUP%20to%20all%20jobs%2C%20running%20or%20stopped