Skip to content

Graceful shutdown on SIGINT/SIGTERM snippet

Graceful shutdown is finishing in-flight work when SIGINT/SIGTERM arrives instead of dying mid-request.

Graceful shutdown is finishing in-flight work when SIGINT/SIGTERM arrives instead of dying mid-request. The naive handler that just exits drops connections and corrupts half-written files. The pattern everywhere: catch the signal, STOP accepting new work, finish (or time-box) what is running, then exit — and watch the double-Ctrl-C: the second signal should abort the drain. Go and Zig make the channel/context plumbing explicit; higher runtimes hand you a lifecycle hook.

Runnable recipe · 12 languages
System & CLIsignalssigtermsigintshutdowndrainprocess-lifecycle

Every language

12 implementations, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
const server = http.createServer(handler);

let draining = false;
process.on('SIGTERM', drain); // Ctrl-C: process.on('SIGINT', drain)

function drain() {
  if (draining) process.exit(1);        // the SECOND signal aborts the drain
  draining = true;
  server.close(() => process.exit(0));  // stops accepting; waits for in-flight
  server.closeIdleConnections();        // Node >= 18.2: cut keep-alive idlers
  setTimeout(() => process.exit(1), 10_000).unref(); // time-box the wait
}

close() stops new connections and waits for in-flight ones — with no timeout, one straggler hangs the exit forever, hence the unref'd timer. SIGKILL (kill -9) cannot be caught; the timer is what keeps the drain bounded before an orchestrator sends it.

TSTypeScript
const inFlight = new Set<Promise<unknown>>();
function track<T>(p: Promise<T>): Promise<T> {
  inFlight.add(p);
  return p.finally(() => inFlight.delete(p)); // counted, and never leaked
}

async function shutdown(close: () => Promise<void>, timeoutMs: number): Promise<void> {
  await close();                                   // stop accepting new work
  const drain = Promise.allSettled([...inFlight]); // waits ALL — one rejecting
                                                   // task can't abort the others
  const timer = new Promise(r => setTimeout(r, timeoutMs));
  await Promise.race([drain, timer]);              // drain fully, or time-box out
}

Typed shutdown() makes the phases visible: close → drain → time-box. allSettled (not all) is the trap-namer — with all(), the first rejection skips every other task's drain. track() is the seam every handler passes through to get counted.

GoGo
import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(),
		os.Interrupt, syscall.SIGTERM)
	defer stop()

	srv := &http.Server{Addr: ":8080"}
	go func() { _ = srv.ListenAndServe() }()

	<-ctx.Done() // first SIGINT/SIGTERM cancels the context
	stop()       // restore default behavior: a SECOND signal now
	             // terminates the process instead of hanging the drain

	drainCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(drainCtx); err != nil { // waits for in-flight requests
		log.Printf("drain incomplete: %v", err)
	}
}

NotifyContext + Shutdown is the whole pattern: Done fires on the first signal, and calling stop() afterward re-arms the DEFAULT handler so a second Ctrl-C hard-exits rather than hanging the drain. Shutdown closes listeners and idle conns then waits — WithTimeout is the time-box.

RsRust
use tokio::signal::unix::{signal, SignalKind};
use tokio_util::sync::CancellationToken;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let token = CancellationToken::new();
    let worker = tokio::spawn(server(token.clone())); // tasks select! on
                                                      // token.cancelled()
    let mut term = signal(SignalKind::terminate())?; // SIGTERM
    tokio::select! {
        _ = tokio::signal::ctrl_c() => {}, // SIGINT (Ctrl-C)
        _ = term.recv() => {},
    }

    token.cancel(); // stop accepting; every task drains, then returns
    tokio::time::timeout(std::time::Duration::from_secs(10), worker).await??;
    Ok(())
}

select! races Ctrl-C and SIGTERM into one drain path; a CancellationToken fans the cancel out to every task (JoinSet is the other route). Pure std Rust has no answer beyond libc signal handlers — the runtime (or a framework) is the practical route.

PHPPHP
pcntl_async_signals(true); // dispatch handlers on ARRIVAL, not next tick

$draining = false;
pcntl_signal(SIGTERM, function () use (&$draining) {
    $draining = true; // set state only — the loop below does the work
});

while (!$draining) {
    $job = $queue->pop();
    process($job); // finish the in-flight job, THEN exit — never mid-job
}

// under PHP-FPM, request-level grace instead:
fastcgi_finish_request(); // flush the response to the client now,
                           // keep the worker alive to finish slow logging

pcntl_async_signals(true) is the line people forget — without it the handler only runs when the engine ticks, which a long job never does. fastcgi_finish_request is FPM's grace: the response goes out, the worker stays to finish side effects.

PyPython
import signal, threading
from concurrent.futures import ThreadPoolExecutor

shutdown = threading.Event()

def request_shutdown(signum, frame):
    shutdown.set()  # state only — the loop below does the draining

signal.signal(signal.SIGTERM, request_shutdown)

try:
    with ThreadPoolExecutor(max_workers=8) as pool:  # exit = shutdown(wait=True)
        while not shutdown.is_set():
            job = next_job()
            if job is not None:
                pool.submit(handle, job)  # in-flight futures drain at with-exit
except KeyboardInterrupt:  # SIGINT's exception form — treat it the same:
    shutdown.set()           # the with-exit STILL waited for the futures

The handler sets an Event; the main loop checks it — never drain inside the handler. KeyboardInterrupt IS SIGINT as an exception: catch it around the loop and set the same Event so both signals share one drain path. The executor's with-exit runs shutdown(wait=True), so pending futures finish.

C#C#
using var cts = new CancellationTokenSource();

Console.CancelKeyPress += (_, e) => {
    e.Cancel = true;  // swallow the default terminate — this code owns the exit
    cts.Cancel();
};
AppDomain.CurrentDomain.ProcessExit += (_, _) => cts.Cancel(); // SIGTERM path

await RunServerAsync(cts.Token);
// every loop and I/O call takes the token: draining = finish current work,
// observe the token, stop. The token glues the whole app together.

Console.CancelKeyPress is Ctrl-C only (a second Ctrl-C still hard-kills); ProcessExit is the closest .NET gets to a SIGTERM hook. In ASP.NET Core, IHostApplicationLifetime.StopAsync is the framework's version — host.RunAsync() wires both signals to the token for you.

JvJava
ExecutorService executor = Executors.newFixedThreadPool(8);

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    executor.shutdown(); // stop taking new work
    try {
        if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
            executor.shutdownNow(); // time-box exceeded: interrupt the rest
        }
    } catch (InterruptedException ie) {
        executor.shutdownNow();
        Thread.currentThread().interrupt();
    }
}));

The hook runs on SIGTERM AND on normal exit — install it once in main. shutdown() vs shutdownNow() is the drain-then-force pair: the first stops intake and waits, the second INTERRUPTS running tasks; awaitTermination's timeout decides which one wins.

SwSwift
import Dispatch
import Foundation

signal(SIGTERM, SIG_IGN) // replace the default FIRST — a DispatchSource only
signal(SIGINT, SIG_IGN)   // fires for signals nobody else handles

let source = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
source.setEventHandler {
    source.cancel()
    drainThenExit() // stop accepting; finish in-flight; exit(0)
}
source.resume()

dispatchMain() // keep the main queue alive (or hang off your run loop)

Install order is the trap: signal(SIGTERM, SIG_IGN) must precede the DispatchSource, or the default action kills the process before the handler ever runs. On Linux, Foundation's SignalHandling differs — DispatchSource is the macOS tool.

KtKotlin
import kotlinx.coroutines.*

fun main() = runBlocking {
    val root = launch { serverLoop() }  // structured: workers are its children

    val stopped = CompletableDeferred<Unit>()
    Runtime.getRuntime().addShutdownHook(Thread { stopped.complete(Unit) })
    stopped.await()                     // SIGTERM (or normal exit) lands here

    root.cancelAndJoin()                // cancellation flows DOWN the tree; each
}                                       // child drains at its next checkpoint

Same JVM shutdown hook as Java, in coroutine form: the hook completes a Deferred the main coroutine awaits, then cancelAndJoin() lets structured concurrency drain every child. Cancellation is cooperative — children finish their current step at ensureActive()/delay() checkpoints.

RbRuby
require 'socket'

draining = false
Signal.trap('TERM') { draining = true } # set state ONLY — trap bodies run in
                                        # a special context; no long work there

server = TCPServer.new(8080)
until draining
  ready, = IO.select([server], nil, nil, 0.5) # timeout re-checks the flag
  next unless ready
  client = server.accept
  handle(client) # finish in-flight, then the loop re-checks the flag
end

The trap body runs in a special signal context — set a flag (or push to a Queue the loop owns), never run long work inside it. IO.select's timeout is what lets a blocked loop notice the flag; without it, accept() sleeps through the signal.

ZigZig
const std = @import("std");

var shutdown_requested = std.atomic.Value(bool).init(false);

fn onSignal(_: c_int) callconv(.C) void {
    // async-signal-safe: writing a lock-free atomic is ALL a handler may do —
    // no allocation, no locks, no printf
    shutdown_requested.store(true, .seq_cst);
}

pub fn main() !void {
    const act = std.posix.Sigaction{
        .handler = .{ .handler = onSignal },
        .mask = std.posix.empty_sigset,
        .flags = 0,
    };
    std.posix.sigaction(std.posix.SIG.TERM, &act, null);
    std.posix.sigaction(std.posix.SIG.INT, &act, null);

    while (!shutdown_requested.load(.seq_cst)) {
        // accept + serve; the flag is checked BETWEEN requests,
        // so in-flight work finishes before the loop falls out
    }
}

sigaction + a global atomic flag is the whole mechanism — Zig gives you no runtime to hide it behind. Async-signal-safety is the rule: the handler may only write a sig_atomic_t / atomic bool; allocating or logging inside it is undefined behavior. Check the flag between units of work, never inside a half-done write.