Key points
- Catch SIGINT and SIGTERM in one place, and print a short message before cleanup starts.
- Write output to a temporary file and rename it at the end, so an interrupt leaves no partial file.
- Stop and wait for child processes, then end through SIGINT so the shell reports code 130.
How do you handle Ctrl-C (SIGINT) in a CLI?
To handle Ctrl-C in a command-line interface (CLI), catch the SIGINT signal, print a short message, and stop the work in progress. Then remove partial files, stop child processes, and exit so that the shell reports exit code 130. SIGINT is the interrupt signal a terminal sends on Ctrl-C, and this orderly stop is called a graceful shutdown.
The signal(7) manual page describes SIGINT, signal 2, as "Interrupt from keyboard." By default it ends the process wherever it is, which can leave a partial file behind. A CLI should handle SIGTERM, which kill sends by default, the same way.
The terminal sends SIGINT to its whole foreground process group, so a CLI's child processes usually receive it too. The Command Line Interface Guidelines say that a CLI should "exit as soon as possible" after Ctrl-C.
What do you need before you start?
Adding interrupt handling needs four things:
- A long run. A short command finishes before the signal arrives, so use a large input.
- A terminal. Ctrl-C becomes SIGINT only in a terminal, or TTY. Scripts and tests send the signal directly.
- A list of side effects. Each file or process that the command creates needs a cleanup step.
- Signal and process tools.
kill -INT <pid>sends SIGINT to one process, andps -eo pid,pgid,argsshows the process group of each process.
How do you handle Ctrl-C in a CLI step by step?
Here is an illustrative example. Acme Co. sells furniture online, and its Python command-line tool, acme, exports orders. The export starts a Secure Shell (SSH) tunnel to the order database as a child process.
1. Reproduce the interrupt
A developer at Acme starts a large export in a terminal and presses Ctrl-C partway through:
$ acme export --format csv --output orders.csv
acme: exporting 12,480 orders to orders.csv
^CTraceback (most recent call last):
...
KeyboardInterrupt
$ echo $?
130
$ wc -l < orders.csv
4211
The exit code is already right, because Python 3.8 and later end through SIGINT when nothing catches KeyboardInterrupt. The file is wrong. It holds a third of the orders as valid CSV, with no sign that the export stopped.
2. Catch SIGINT and SIGTERM in one place
The SIGINT handler prints a line at once and restores the default action for a second Ctrl-C. The SIGTERM handler raises SystemExit, so the same finally blocks run for both signals:
def on_sigint(signum, frame):
print("acme: interrupted, cleaning up (Ctrl-C again to quit now)",
file=sys.stderr)
signal.signal(signal.SIGINT, signal.SIG_DFL) # a second Ctrl-C ends it
raise KeyboardInterrupt
def run():
signal.signal(signal.SIGINT, on_sigint)
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(143))
try:
main()
except KeyboardInterrupt:
sys.stdout.flush() # os.kill skips Python's own exit steps
os.kill(os.getpid(), signal.SIGINT) # no traceback, and code 130
Other runtimes have the same hooks:
- Node.js. A listener added with
process.on("SIGINT", ...)removes the default exit, so the listener has to clean up and exit on its own. - Go.
signal.NotifyContextreturns a context that is canceled when a listed signal arrives. After itsstopfunction runs, a second Ctrl-C ends the program. - C. Many functions are not safe inside a signal handler, as the signal-safety(7) manual page states. The handler sets a flag, and the main loop cleans up.
3. Write to a temporary file and rename it at the end
The export writes to orders.csv.tmp and renames it after the last row. The rename(2) manual page states that an existing target "will be atomically replaced," so a reader finds the previous file or the complete one. A rename works only within one file system, so the temporary file sits next to the target:
def export(rows, path):
tmp = path + ".tmp"
try:
with open(tmp, "w") as f: # mode "w" empties a leftover copy
for row in rows:
f.write(row)
os.replace(tmp, path) # the finished file appears in one step
finally:
if os.path.exists(tmp): # still there, so the run stopped early
os.remove(tmp)
The finally block also runs for KeyboardInterrupt, which then continues to the entry point.
4. Stop child processes and wait for them
A real Ctrl-C reaches the tunnel too, but a SIGTERM sent with kill reaches only the export. If the export exits without stopping it, the tunnel keeps running and holds port 5433. The _exit(2) manual page states that init, or a subreaper, inherits the children of an exiting process. Such a child is an orphan process.
The export stops the tunnel and waits, with SIGKILL as a last resort that no process can catch or ignore:
tunnel = subprocess.Popen(
["ssh", "-N", "-o", "ExitOnForwardFailure=yes", # fail if 5433 is taken
"-L", "5433:localhost:5432", "db.example.com"])
try:
export(fetch_rows(port=5433), args.output)
finally:
tunnel.terminate() # SIGTERM, and no effect if the tunnel already exited
try:
tunnel.wait(timeout=5)
except subprocess.TimeoutExpired:
tunnel.kill() # SIGKILL
tunnel.wait()
5. Limit cleanup time and let a second Ctrl-C quit
The Command Line Interface Guidelines ask for a timeout on cleanup, here 5 seconds for the tunnel. They also advise skipping slow cleanup on a second Ctrl-C, which the default action from step 2 does. If skipping cleanup could destroy data, the first message should say what a second Ctrl-C will do.
6. Make cleanup safe to run again
A second Ctrl-C or a SIGKILL can stop cleanup halfway, so each run must expect leftovers. Cleanup that has idempotency gives the same result whether it runs once or several times. Mode w empties a leftover orders.csv.tmp.
This example is simplified. A real CLI would also restore terminal settings it changed, e.g. a hidden cursor.
What exit code should an interrupted CLI return?
An interrupted CLI should end so that the shell reports code 130, which is 128 plus signal 2. The cleanest way is to restore the default action and send SIGINT to the process itself, as the example's handler and run function do.
The Bash manual page states that a script waiting for a command treats SIGINT as fatal only if the command ended because of it. An exit(130) is a normal exit, so a loop in the script can keep going. SIGTERM gives 143, which is 128 plus signal 15, and SIGPIPE gives 141.
What changes when a coding agent writes the code?
A coding agent often tests an export by calling its functions and checking the file. None of those tests sends a signal, so the interrupt path never runs. Nobody presses Ctrl-C during the agent's own commands either, and a smoke test of startup ends before a signal could arrive.
Handlers that an agent writes can read correctly in a diff and fail when run, e.g. a handler that prints "Canceled." and returns, so the export continues. A handler can also remove the file and leave the tunnel running.
A practical adjustment is one test per long-running command that sends SIGINT partway through and checks the exit code, the files, and the processes left behind. Interrupting a long run is also a step to verify a CLI that an agent built.
What are common mistakes?
These mistakes break interrupt handling, or hide it from tests:
- Catching the interrupt and carrying on. Python's
except Exceptiondoes not catchKeyboardInterrupt, but a bareexcept:does. Inside a retry loop, it turns Ctrl-C into another attempt. - Expecting the signal at a quiet moment. SIGINT can arrive between any two statements, e.g. inside a
finallyblock, which then stops halfway. Cleanup then has the timing problems of a race condition. - Running the work in another thread. Python runs signal handlers only in the main thread, so an export loop in a worker thread keeps writing after Ctrl-C.
- Signaling a background job from a script. Background commands in a script start with SIGINT ignored, as the Bash manual page states. A Python program without its own handler keeps that setting, so
kill -INTdoes nothing. - Sending the signal too early. A signal that arrives before the handler is installed gets default handling, so the test becomes a flaky test. Wait for a line that the program prints after its handlers and cleanup are in place.
How do you check that it worked?
This pytest test runs the built command in its own process group, waits for its first line, and signals the whole group, as a terminal does:
def test_ctrl_c_leaves_no_partial_file_or_process(tmp_path):
out = tmp_path / "orders.csv"
proc = subprocess.Popen(
["acme", "export", "--format", "csv", "--output", str(out)],
stderr=subprocess.PIPE, text=True, start_new_session=True,
)
assert "exporting" in proc.stderr.readline() # the run has started
os.killpg(proc.pid, signal.SIGINT) # the whole group, as Ctrl-C does
assert proc.wait(timeout=10) == -signal.SIGINT # ended through SIGINT
with pytest.raises(ProcessLookupError): # no tunnel left in the group
os.killpg(proc.pid, 0)
assert "cleaning up" in proc.stderr.read() # a live tunnel would block this
assert not out.exists() and not (tmp_path / "orders.csv.tmp").exists()
A second run with proc.send_signal(signal.SIGTERM) signals only the export and expects 143. The group check then shows whether the export stopped its tunnel.
A test of the keystroke path runs the command in a pseudoterminal and writes the interrupt character, e.g. with pexpect's sendintr(). Varying the moment of the signal makes each run a small fault injection, the method behind chaos engineering. A passing test covers the moments it tried, not every moment a signal can arrive.
FAQs
What is the difference between SIGINT and SIGTERM?
SIGINT and SIGTERM both ask a program to stop, and both end it by default. A terminal sends SIGINT to the whole foreground process group on Ctrl-C, while the kill command sends SIGTERM by default to one process. A CLI usually runs the same cleanup for both and exits with a different code.
What should a CLI do on a second Ctrl-C?
A second Ctrl-C should make a CLI skip the rest of its cleanup and exit at once. If skipping cleanup could destroy data, the message after the first Ctrl-C should say what a second one will do. The next run then has to expect the leftovers.
How do you stop child processes when a CLI exits?
A CLI stops its child processes by sending each one SIGTERM and waiting for it to exit, with SIGKILL after a time limit. A child that nothing stops keeps running after its parent exits. It becomes an orphan process, and init or a subreaper inherits it.
How do you send SIGINT to a program in a test?
A test sends SIGINT with a kill call, e.g. to the program's process group, once the program prints a line after setting up its handlers and cleanup. For the keystroke path, the test runs the program in a pseudoterminal and writes the interrupt character.