New POSIX Signal Handling Features in .NET 6

Changelog:

Introduction

.NET 6 introduced a new feature which enables limited handling of POSIX Signals, also called Linux/Unix signals.

This article introduces this feature, and is primarily aimed at Windows developers and others that may not be familiar with systems-level Linux/Unix programming and conventions. I hope it'll make developing applications that target Linux, MacOS, and even WSL slightly less scary.

All examples in F#.

POSIX Signals

(Skip this section if you already know what POSIX signals are.)

POSIX Signals, which is the more formal and standardized name for Linux/Unix signals, are low-level messages that processes can send to one another. It is one of the oldest interprocess communication mechanisms in use today. They are used extensively for coordinating communication between related processes, and for controlling the process life cycle.

Some basic things you might want to know:

The New Classes

The primary new class of interest is PosixSignalRegistration in System.Runtime.InteropServices. This class has a Create method which accepts a signal identifier and a handler as parameters:

open System.Runtime.InteropServices

PosixSignalRegistration.Create(PosixSignal.SIGINT, fun context -> 
    context.Cancel <- true) 
|> ignore

PosixSignal is an enum containing available signal identifiers. Currently, the signals included (and thus presumably supported by .NET) are:

This is only a small subset of POSIX signals. Hopefully, more will be added in future versions. Of these, the most interesting are the four "termination" signals SIGHUP, SIGINT, SIGTERM, and SIGQUIT. They are called termination signals because the default behavior is to terminate the process. The others are likely not interesting unless you have very specific needs. We will cover all of these below.

The second parameter to PosixSignalRegistration.Create, the signal handler, accepts a PosixSignalContext. The context object contains a single property of interest called context.Cancel. If set to true, it cancels the default behavior of the signal. If set to false (the default), the default behavior occurs after the handler.

SIGINT

The SIGINT signal is meant to 'interrupt' a running application. You can send a console applications running in the foreground (i.e. an application you started in a shell, without a & at the end) a SIGINT signal by pressing Ctrl-C. The default behavior of SIGINT is to terminate a process.

(Of course, you can also explicitly send a SIGINT (or any other signal) to a process using the Linux kill command: kill -INT 12345)

Below, we see a SIGINT handler in action:

open System.Threading
open System.Runtime.InteropServices

let mutable intPreviouslyReceived = false

PosixSignalRegistration.Create(PosixSignal.SIGINT, fun context -> 

    if intPreviouslyReceived then
        printfn "Ok, you asked for it!" 

        // should we cancel the default signal handling behavior?
        context.Cancel <- false // (this is the default)
    else
        printfn "Are you sure you want to quit? Press Ctrl-C again if you're sure" 
        intPreviouslyReceived <- true

        // actually quit this time
        context.Cancel <- true) 
|> ignore

// just some busy work so the app doesn't naturally close immediately
for i in 1 .. 30 do
    printfn "%d" i 
    Thread.Sleep 1000 |> ignore

The first time a SIGINT is received by the above script, we prevent the default behavior by setting context.Cancel to true and set intPreviouslyReceived to true. The second time the script receives SIGINT, it terminates itself for real.

SIGHUP

SIGHUP is the "hang up" signal. Like SIGINT, its default behavior is to terminate the receiving process. SIGHUP has two "interpretations".

For console application, SIGHUP is sent to an application when its controlling terminal closes. This can happen in a few ways, including:

  1. You close the window of the terminal running your console application;
  2. You disconnect from an SSH session with a process running; or
  3. Historically, when your 300-baud dial-up connection to the mainframe at your University was dropped. This is where the term "hang up" comes from.

The snippet below would prevent your application from dying under the above circumstances:

PosixSignalRegistration.Create(PosixSignal.SIGHUP, fun context -> 
    context.Cancel <- true) 
|> ignore

Note that the above can also be achieved by running any Unix command with the 'nohup'.

You can test out the above handler by monitoring it's parent process:

  1. Run the SIGHUP-handling program normally.
  2. Open a process monitor with a tree-view, like htop or Ubuntu's System Monitor. Note that the process's parent will be the shell you ran the command in.
  3. Close the terminal.
  4. Find the process in the monitor -- you'll see it is still running, but it will be the child of the init process (the topmost process in Unix with process ID 1). (Processes whose parent died gets 'adopted' by the init process).

For server/daemon applications which do not usually have a controlling terminal, SIGHUP is used in a completely different way: A SIGHUP received by a process is generally interpreted as a signal to reload its configuration files.

For example, after you change your nginx.conf or other nginx configuration files, running nginx reload will send nginx a SIGHUP. nginx will then re-read the configuration files and change its runtime parameters to reflect the changes. Because it does not stop and restart the entire daemon, it maintains and serves existing client connections during the reload.

SIGTERM and SIGQUIT

SIGTERM and SIGQUIT are the signals meant for ending a process. Their default behavior is the same: end the process. Actually, SIGQUIT also generates a core dump file, by default. Handling these two signals can be accomplished in exactly the same way as SIGHUP and SIGINT above.

SIGTERM is meant to be the "polite" way of telling a process to terminate. A process receiving SIGTERM should close all temporary files, free all resources, write to logs appropriately, etc.

SIGQUIT is meant to be, by convention, a more "urgent" way of telling a process to terminate. A process receiving SIGQUIT may skip cleaning up temporary files or other non-critical tasks.

Your handlers for these signals should strive to meet these convention and clean up accordingly.

SIGKILL

SIGKILL is the "forceful" way of ending a process. It stops the process dead in its tracks without any cleanup of any kind.

There is no SIGKILL in the PosixSignal enum because SIGKILL cannot be caught or ignored by a process. It is meant to be a last-resort to terminate a process, when a process refuses to quit upon receiving a SIGQUIT and/or SIGTERM.

As a side note: this means that if you have some process you want to end on your machine, you should not start with a kill -KILL $pid. In fact, the default signal for the kill command (when you don't specify a signal) is SIGTERM. Start with SIGTERM, then SIGQUIT, then SIGKILL if necessary.

SIGTSTP and SIGCONT

SIGTSTP and SIGCONT are the signals sent by the Ctrl-Z and fg, respectively.

(Background for non-Unix people: you can suspend most running processes in Unix by pressing Ctrl-Z and then resume it later by 'foregrounding' it with fg. It isn't too popular these days since modern systems can run many terminal windows. However, I still do this often if I'm in an SSH session and I don't have tmux or screen available (or configured to my liking).)

Generally, you wouldn't be messing with these signals since the default behavior is sufficient and is expected from users.

Also, SIGTSTP and SIGCONT handling seem broken as of now. See the github issue I submitted.

SIGTTIN and SIGTTOU

SIGTTIN and SIGTTOU are sent when a background process tries to read or write from a parent's terminal session (the 'controlling terminal'). Like SIGTSTP and SIGCONT, these are unlikely to be useful unless you are doing something very Unix-y, such as implementing a Unix shell like Bash.

SIGCHLD

SIGCHLD is a signal received by a parent process when a child process (any of them, if there are multiple children) terminates. However, I don't think this will be used often from .NET since .NET programmers would and should prefer System.Diagnostics.Process.Start() to launch child processes.

(Linux programmers using C or other Unix-native language would often use SIGCHLD handlers in conjunction with other Unix classics like fork, pipe and the SIGPIPE to launch and manage child processes. However, using fork directly from .NET (technically possible through P/Invoke) is ill-advised, unless you're one of the few people in the world that can understand how .NET multithreading and forking interact, on Linux muchless.)

SIGWINCH

SIGWINCH is received when the window is resized... like, when you click on the corner of a window and drag it to resize it. This signal might be interesting to those writing terminal-UI's with curses (think: the top command), or animated terminal applications like progress bars.

Other signals

Signals that are not listed in PosixSignal can be caught by providing PosixSignalRegistration.Create a raw signal number (cast to the PosixSignal enum type). You can find the raw signal numbers by looking at man 7 signal on Linux, or man 3 signal on MacOS.

For example, on my Linux machine, we see:

   Signal        x86/ARM     Alpha/   MIPS   PARISC   Notes
               most others   SPARC
   ----------------------------------------------------------
   SIGHUP           1           1       1       1
   SIGINT           2           2       2       2
   SIGQUIT          3           3       3       3
   SIGILL           4           4       4       4
   SIGTRAP          5           5       5       5
   SIGABRT          6           6       6       6
   SIGIOT           6           6       6       6
   SIGBUS           7          10      10      10
   SIGEMT           -           7       7      -
   SIGFPE           8           8       8       8
   SIGKILL          9           9       9       9
   SIGUSR1         10          30      16      16
   SIGSEGV         11          11      11      11
   SIGUSR2         12          31      17      17
   SIGPIPE         13          13      13      13
   ....

Note that they are architecture AND OS dependent, so please code accordingly!

We see above that the user-definable signal SIGUSR1 is 10 on my system. To catch the SIGUSR1 signal:

PosixSignalRegistration.Create( enum<PosixSignal>(10), fun context -> 
    printfn "usr1 received" 
    context.Cancel <- true) 
|> ignore

Before going crazy with handling unlisted signal, though, please be aware that POSIX signal handling has a lot of nuances, some of which may not make sense in a .NET application. Users are encouraged to read more about signal handling in a traditional Unix programming book such as Steven's Advanced Programming in the Unix Environment.