Every language
12 linguagens, 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 loggingpcntl_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 futuresThe 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 checkpointSame 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
endThe 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.