std.signal

OS signal handlers: catch SIGTERM and SIGINT so a process can finish what it started instead of dying wherever it happened to be.

import std.signal

shutdown = signal.on()               # the default set: ["term", "int"]

loop {
    caught = signal.next(shutdown, 0)    # 0 polls and returns immediately
    unless is_null(caught) {
        print("caught {caught} - draining")
        break
    }
    process_one_item()
}

flush_leases()
signal.close(shutdown)

The case this exists for is a rolling deploy. The orchestrator sends SIGTERM and then waits. A process that cannot see it dies mid-write, still holding a queue lease, with the run's cost unrecorded. Catching it turns that into an orderly exit.

Capability

Installing a handler needs exec. A signal disposition is process-global, so a package that can install one can swallow the operator's Ctrl-C, or stop an orchestrator's SIGTERM from ever being seen. That is process control, the same authority os.exit needs. See capabilities.

Names

Lowercase, without the SIG prefix. signal.names() returns what this platform can actually deliver.

namesignaltypical meaning
termSIGTERMan orchestrator is rolling you; drain and exit
intSIGINTan operator pressed Ctrl-C
hupSIGHUPconventionally "reload your config"
quitSIGQUITquit, traditionally with a core dump
usr1, usr2SIGUSR1/2whatever your service defines

SIGKILL is not in the list because nothing can catch it. Offering it would be a lie.

Windows has no POSIX signals. The console control handler provides Ctrl-C, which is what int means there. The others are refused by name rather than accepted and never fired, so a handler that will never run fails at the point you write it rather than in production.

API

callresult
signal.names()The signal names this platform can deliver.
signal.on(names?)Subscribe. Defaults to ["term", "int"], the two that actually arrive. Returns a handle.
signal.next(handle, timeout_ms?)The signal name, or null once the deadline passes. 0 polls.
signal.close(handle)Stop delivering to this subscription.
signal.raise(name)Send a signal to this process.

Notes

next takes a deadline, and a timeout is null rather than an error. A drain loop checks between units of work with an ordinary if - no callbacks, no separate control flow, and no way for a quiet process to hang with no way out.

Handlers compose. Several subscriptions to the same signal all fire, and std.http's graceful shutdown subscribes through the same registry rather than installing its own. A disposition is process-global, so without a single owner whichever installed last would silently win and the other would stop firing.

Closing the last subscription gives the signal back to the OS. While any subscription is live the handler keeps it, so closing one of several still delivers to the others. When the count reaches zero the signal does what it would have done if you had never subscribed - Ctrl-C interrupts again. Before this, a program that used std.signal once absorbed the signal for the rest of its run and could not be interrupted at all.

Delivery is asynchronous. The handler itself only records that a signal arrived; the fan-out to your subscriptions happens on an ordinary thread moments later. That is what keeps the handler async-signal-safe - it takes no lock and allocates nothing, so a signal landing at an awkward moment cannot deadlock the process. signal.next is unaffected: it waits, and the value arrives.

signal.raise(name) is how to exercise a handler without a second terminal and a kill.

With a server

http.serve already drains on Ctrl-C. Subscribe to term when the thing stopping you is an orchestrator rather than a person:

import std.http
import std.signal

rolling = signal.on(["term"])

async fn drain() {
    signal.next(rolling, null)       # null = wait as long as it takes
    http.stop()
}

drain()
http.serve(8080, handler)
flush_leases()