go-signal

Gopher CircleCI codecov Go Report Card Go Reference Stability: Active

📡 go-signal

A small Go library for coordinating application startup and shutdown hooks around OS signals.

The package centers on a Lifecycle that runs hooks in three phases:

The package-level helpers operate on a process-wide default lifecycle initialized with NewDefaultLifecycle(), which uses a 30-second stop timeout.

signal.ErrTimeout is the package-owned timeout cause used for lifecycle stop contexts and timer stop hooks. It wraps github.com/alexfalkowski/go-sync’s sync.ErrTimeout, which in turn wraps context.DeadlineExceeded.

[!NOTE] Lifecycle helpers recover application panics. A panic from a hook, from a Run handler, or from a Go/Timer handler is converted into an error marked with signal.ErrRecovered instead of skipping lifecycle cleanup or crashing the process. Detect a recovered panic with errors.Is(err, signal.ErrRecovered).

📦 Install

Use the Go toolchain version declared in go.mod.

go get github.com/alexfalkowski/go-signal

🧪 Development

Initialize the shared bin tooling before running Make targets from a fresh clone. The submodule uses GitHub SSH, so SSH access must be configured before this step:

git submodule sync
git submodule update --init

Benchmarks

make benchmarks
make lifecycle-benchmarks
make benchtime=10x benchmark

Fuzz tests

make fuzzes
make lifecycle-fuzz
make timer-fuzz
make terminated-fuzz
make package=. name=FuzzLifecycleRunHookMatrix fuzztime=1000x fuzz

🧭 Core API

🧱 NewDefaultLifecycle

Use NewDefaultLifecycle when you want the package’s standard lifecycle configuration without relying on the package-level singleton.

🧩 Register

Use Register during application setup to add hooks to the default lifecycle. Hook callbacks are optional, and nil callbacks are treated as no-ops.

[!IMPORTANT] Register hooks during single-threaded setup before calling Run or Serve; registration is not concurrent-safe.

signal.Register(signal.Hook{
    OnStart: func(context.Context) error {
        // Acquire resources.
        return nil
    },
    OnStop: func(context.Context) error {
        // Release resources.
        return nil
    },
})

▶️ Run

Run executes all start hooks, then your handler, then all stop hooks.

package main

import (
    "context"
    "log"

    "github.com/alexfalkowski/go-signal"
)

func main() {
    signal.Register(signal.Hook{
        OnStart: func(context.Context) error {
            // Start dependencies.
            return nil
        },
        OnStop: func(context.Context) error {
            // Stop dependencies.
            return nil
        },
    })

    if err := signal.Run(context.Background(), func(context.Context) error {
        // Run application code.
        return nil
    }); err != nil {
        log.Fatal(err)
    }
}

🛎️ Serve

Serve is the long-running variant for processes that should stay alive until shutdown is requested.

[!NOTE] Serve is intended to be used as the final process-lifetime blocking call. It takes ownership of its active signals (the default set plus any given), does not restore prior signal handlers after returning, and callers should normally return from main after Serve returns.

import (
    "context"
    "errors"
    "log"
    "net"
    "net/http"
    "time"

    "github.com/alexfalkowski/go-signal"
)

srv := &http.Server{ReadHeaderTimeout: time.Minute}

signal.Register(signal.Hook{
    OnStart: func(ctx context.Context) error {
        ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", "127.0.0.1:8080")
        if err != nil {
            return err
        }

        return signal.Go(ctx, time.Second, func(context.Context) error {
            if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
                return signal.Terminated(err)
            }

            return nil
        })
    },
    OnStop: func(ctx context.Context) error {
        return srv.Shutdown(ctx)
    },
})

if err := signal.Serve(context.Background()); err != nil {
    log.Fatal(err)
}

🛑 Shutdown

Shutdown sends os.Interrupt to the current process through the default lifecycle. It is mainly useful for programmatic shutdown, such as from tests or from a background goroutine that wants to stop a running Serve loop.

🧯 Terminate

Terminate records a shutdown cause on the default lifecycle, then sends os.Interrupt to the current process. Use it when background work should stop a running Serve loop and have Serve return the terminating cause after stop hooks run.

Passing nil records ErrTerminated. Passing a non-nil error marks it with ErrTerminated unless it is already marked.

📡 Raise

Raise sends any signal.Signal to the current process. Shutdown uses it internally to send signal.Interrupt; call it directly to simulate any other signal a running Serve is watching for, e.g. signal.Raise(signal.Hangup).

🧰 Background helpers

🚀 Go

Go runs a handler with a timeout-aware wait. It is useful for long-running background work that should share a lifecycle context.

import (
    "context"
    "log"
    "time"

    "github.com/alexfalkowski/go-signal"
)

if err := signal.Go(context.Background(), 5*time.Second, func(context.Context) error {
    // Run background work.
    return nil
}); err != nil {
    log.Fatal(err)
}

⏱️ Timer

Timer is a convenience helper for periodic work:

Because Timer executes through Go, it uses best-effort waiting semantics: when the parent context is canceled or the wait timeout elapses first, Timer may return before the timer worker has run hook.OnStop, and late non-terminated hook errors are not returned to the caller. With a valid interval, if the parent context is already done or the timeout is not positive, the timer worker does not start.

Timer also inherits Go’s termination routing: signal.Terminated(err) triggers package-level Terminate, so the recorded cause belongs to Default().

The interval must be greater than zero or Timer returns ErrInvalidInterval.

import (
    "context"
    "time"

    "github.com/alexfalkowski/go-signal"
)

signal.Register(signal.Hook{
    OnStart: func(ctx context.Context) error {
        return signal.Timer(ctx, 5*time.Second, time.Minute, signal.Hook{
            OnStart: func(context.Context) error {
                // Initialize the periodic worker.
                return nil
            },
            OnTick: func(context.Context) error {
                // Perform one scheduled iteration.
                return nil
            },
            OnStop: func(context.Context) error {
                // Flush or clean up.
                return nil
            },
        })
    },
})

🛠️ Custom lifecycle

Use NewLifeCycle when you need a custom stop timeout. Use NewDefaultLifecycle when you want the same 30-second timeout as the package-level default, but on your own lifecycle instance.

Use a positive custom stop timeout. A zero or negative timeout creates an already-expired stop context, so stop hooks that observe the context may return signal.ErrTimeout immediately.

Use SetDefault when package-level helpers such as Register, Run, and Serve should target a custom lifecycle. Passing nil to SetDefault restores a fresh default lifecycle. Default returns the current package-level lifecycle.

Package-level Go and Timer also use the package-level termination path. If you want background signal.Terminated(err) errors to be returned by Serve, install the lifecycle with SetDefault and call package-level Serve. If you call lc.Serve(ctx) directly, use lc.Terminate(err) from your own background work when that lifecycle should stop with a cause.

import (
    "context"
    "log"
    "time"

    "github.com/alexfalkowski/go-signal"
)

lc := signal.NewLifeCycle(10 * time.Second)

lc.Register(signal.Hook{
    OnStart: func(context.Context) error { return nil },
    OnStop:  func(context.Context) error { return nil },
})

if err := lc.Run(context.Background(), func(context.Context) error {
    return nil
}); err != nil {
    log.Fatal(err)
}

To use that lifecycle through the package-level helpers:

signal.SetDefault(lc)
defer signal.SetDefault(nil)

if err := signal.Run(context.Background(), func(context.Context) error {
    return nil
}); err != nil {
    log.Fatal(err)
}

🧪 Example

See cmd/main.go for a runnable example covering Serve, Go, Timer, and termination-triggered shutdown. It is a manual make run example, not an installed or production CLI.

After completing the fresh-clone setup in the Development section, run:

make run param=terminate

Use param=start or param=timer for examples that run until interrupted. Use param=terminate for the example that triggers shutdown from a terminated background worker.