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:
OnStart in registration orderRun or wait for shutdown with ServeOnStop in reverse registration orderThe 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
Runhandler, or from aGo/Timerhandler is converted into an error marked withsignal.ErrRecoveredinstead of skipping lifecycle cleanup or crashing the process. Detect a recovered panic witherrors.Is(err, signal.ErrRecovered).
Use the Go toolchain version declared in go.mod.
go get github.com/alexfalkowski/go-signal
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
make benchmarks
make lifecycle-benchmarks
make benchtime=10x benchmark
make fuzzes
make lifecycle-fuzz
make timer-fuzz
make terminated-fuzz
make package=. name=FuzzLifecycleRunHookMatrix fuzztime=1000x fuzz
Use NewDefaultLifecycle when you want the package’s standard lifecycle
configuration without relying on the package-level singleton.
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
RunorServe; 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 executes all start hooks, then your handler, then all stop hooks.
Run attempts every start hook, even if an earlier one fails.Run rolls back by calling stop hooks for the hooks that
started successfully in reverse registration order.Run executes rollback and stop hooks with a fresh background context bounded
by the lifecycle timeout.context.Cause(ctx) after that stop
context expires, the returned error matches signal.ErrTimeout.Run always runs stop hooks when the handler
returns, even if it returns an error, in reverse registration order.errors.Join.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 is the long-running variant for processes that should stay alive until
shutdown is requested.
signal.Interrupt and signal.Termination (SIGINT and
SIGTERM), or for the parent context to be canceled. Pass signal.Hangup or
any other signal.Signal (an alias for os.Signal) to extend that default
set, e.g. signal.Serve(ctx, signal.Hangup).context.Cause(ctx) after that stop context expires,
the returned error matches signal.ErrTimeout.Shutdown()
returns nil unless startup, rollback, or stop hooks return errors.Terminate(err) returns the terminating cause, joined with any
stop-hook errors.Go’s wait window has
elapsed, wrap its error with signal.Terminated(err) so Go can request
shutdown from the background goroutine.Go and Timer request shutdown through package-level
Terminate, which records causes on Default(). If you call
lifecycle.Serve(ctx) directly on a custom lifecycle, background work that
should stop that lifecycle must call lifecycle.Terminate(err), or you should
install the lifecycle with SetDefault and use package-level Serve.[!NOTE]
Serveis 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 frommainafterServereturns.
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 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 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 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).
Go runs a handler with a timeout-aware wait. It is useful for long-running
background work that should share a lifecycle context.
Go only reports errors observed before its timeout or parent context
cancellation.Go
returns nil without starting the handler.signal.Terminated(err), Go
triggers package-level Terminate(err) before returning the error.Terminate(err) is still triggered, but Go has already returned nil.Terminate targets Default(). Go does not infer a custom
Lifecycle from the context passed to it.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 is a convenience helper for periodic work:
hook.OnStart oncehook.OnTick on each intervalhook.OnStart fails, or when the parent context is canceled or a timer
hook returns an error, the timer worker calls hook.OnStop with a fresh
background context bounded by the supplied timeoutcontext.Cause(ctx), the
returned error matches signal.ErrTimeoutBecause 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
},
})
},
})
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)
}
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.