Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 54 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
# Exitplan

[![Go Reference](https://pkg.go.dev/badge/github.com/struct0x/exitplan.svg)](https://pkg.go.dev/github.com/struct0x/exitplan)
![Coverage](https://img.shields.io/badge/Coverage-83.2%25-brightgreen)

A Go library for managing the lifecycle of an application with graceful shutdown capabilities.

## Overview

The Exitplan library provides a simple mechanism for managing the lifetime of an application.
It helps you handle application startup, running, and shutdown phases with proper resource cleanup.
The Exitplan library provides a simple mechanism for managing the lifetime of an application.
It helps you handle application running, and shutdown phases with proper resource cleanup.

Key features include:

- Distinct application lifecycle phases (starting, running, teardown)
- Distinct application lifecycle phases (running, teardown)
- Context-based lifecycle management
- Graceful shutdown with customizable timeout
- Flexible callback registration for cleanup operations
Expand All @@ -27,20 +28,49 @@ go get github.com/struct0x/exitplan

## Lifecycle Phases

Exitplan splits application lifetime into three phases, each with its own context:

- **Starting**: before `Run()` begins. Use `StartingContext()` for initialization.
It is canceled immediately when `Run()` starts.
Exitplan manages two lifecycle phases:

- **Running**: active between `Run()` and `Exit()`. Use `Context()` for workers and other long-running tasks.
It is canceled as soon as shutdown begins.
- **Running**: active between `Run()` and `Exit()`. Use
`Context()` for workers and other long-running tasks.
It is canceled as soon as shutdown begins (via `Exit()`, signal, or startup timeout).

- **Teardown**: after `Exit()` is called. Use `TeardownContext()` in shutdown callbacks.
It is canceled when the global teardown timeout elapses.

Use
`Started()` to receive a signal when the application enters the running phase.
This is useful for readiness probes or coordinating dependent services.

### Startup Timeout

Use `WithStartupTimeout()` to detect stuck initialization:

```go
package main

import (
"time"

"github.com/struct0x/exitplan"
)

func main() {
_ = exitplan.New(
exitplan.WithStartupTimeout(10 * time.Second),
)

// If Run() isn't called within 10 seconds,
// Context() is canceled and teardown begins
}

```

This is useful when initialization depends on external services that might hang.

### Callback ordering

Shutdown callbacks registered with `OnExit*` are executed in **LIFO order** (last registered, first executed).
Shutdown callbacks registered with `OnExit*` are executed in **LIFO order
** (last registered, first executed).
This mirrors resource lifecycles: if you start DB then HTTP, shutdown runs HTTP then DB.
Callbacks marked with `Async` are awaited up to the teardown timeout.

Expand Down Expand Up @@ -107,13 +137,18 @@ func main() {
}),
)

// Use the starting context for initialization
startingCtx := ex.StartingContext()
_ = startingCtx
// Initialize resources with the starting context
// Signal readiness when Run() starts
go func() {
<-ex.Started()
fmt.Println("Application is now running and ready")
// e.g., signal readiness probe, notify dependent services
}()

// For example, pinging a database connection to ensure it is ready, yet it should not freeze the application
// err := db.Ping(startingCtx)
// Initialize resources before Run()
// Use context.WithTimeout() if you need bounded initialization
// ctx, cancel := context.WithTimeout(ex.Context(), 5*time.Second)
// defer cancel()
// err := db.Ping(ctx)

// Register cleanup with context awareness
ex.OnExitWithContext(func(ctx context.Context) {
Expand Down Expand Up @@ -145,16 +180,16 @@ func main() {
fmt.Println("Application starting...")

// Get the running context to use in your application
runningCtx := ex.Context()
ctx := ex.Context()

// Start a worker that respects the application lifecycle
workerDone := make(chan struct{})
go func() {
for {
select {
case <-runningCtx.Done():
case <-ctx.Done():
fmt.Println("Worker shutting down...")
time.Sleep(100 * time.Millisecond) // Simulate some work
time.Sleep(100 * time.Millisecond) // Simulate some teardown work
close(workerDone)
return
case <-time.After(1 * time.Second):
Expand Down
32 changes: 32 additions & 0 deletions callbacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,25 @@ package exitplan

import (
"context"
"fmt"
"path/filepath"
"runtime"
"time"
)

type CallbackErr struct {
Name string
Err error
}

func (e *CallbackErr) Error() string {
return fmt.Sprintf("callback %s: %v", e.Name, e.Err)
}

func (e *CallbackErr) Unwrap() error {
return e.Err
}

type exitCallbackOpt func(*callback)

// Async sets the callback to be executed in a separate goroutine.
Expand All @@ -26,6 +42,13 @@ func Timeout(timeout time.Duration) exitCallbackOpt {
}
}

// Name sets callback name, used for identification.
func Name(name string) exitCallbackOpt {
return func(c *callback) {
c.name = name
}
}

type executeBehaviour int

const (
Expand All @@ -41,8 +64,17 @@ const (
)

type callback struct {
name string
executeBehaviour executeBehaviour
errorBehaviour exitBehaviour
timeout time.Duration
fn func(context.Context) error
}

func callerLocation(skip int) string {
_, file, line, ok := runtime.Caller(skip)
if !ok {
return "unknown"
}
return fmt.Sprintf("%s:%d", filepath.Base(file), line)
}
34 changes: 17 additions & 17 deletions exitplan.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
/*
Package exitplan implements a simple mechanism for managing a lifetime of an application.
It provides a way to register functions that will be called when the application is about to exit.
It distinguishes between starting, running and teardown phases.

The application is considered to be starting before calling Exitplan.Run().
You can use Exitplan.StartingContext() to get a context that can be used to control the startup phase.
Starting context is canceled when the startup phase is over.

The application is considered to be running after calling Exitplan.Run() and before calling Exitplan.Exit().
You can use Exitplan.Context() to get a context that can be used to control the running phase.
It is canceled when the application is about to exit.
Use Exitplan.Started() to receive a signal when the application enters the running phase.
Use Exitplan.Context() to get a context bound to the application lifetime.

The application is considered to be tearing down after calling Exitplan.Exit().
You can use Exitplan.TeardownContext() to get a context that can be used to control the teardown phase.
Expand Down Expand Up @@ -107,15 +102,13 @@ func (l *Exitplan) start() {
l.startingCancel = cancel
}

// StartingContext returns a context for a starting phase. It can be used to control the startup of the application.
// StartingContext will be canceled after the starting timeout or when Exitplan.Run() is called.
func (l *Exitplan) StartingContext() context.Context {
return l.startingCtx
// Started returns a channel that is closed after Run is called.
func (l *Exitplan) Started() <-chan struct{} {
return l.startingCtx.Done()
}

// Context returns a main context. IT will be canceled when the application is about to exit.
// It can be used to control the lifetime of the application.
// It will be canceled after calling Exitplan.Exit().
// Context returns a main context. It will be canceled when the application is about to exit.
// It can be used to control the lifetime of the application (via Exit(), signal, or startup timeout).
func (l *Exitplan) Context() context.Context {
return l.runningCtx
}
Expand Down Expand Up @@ -180,7 +173,8 @@ func (l *Exitplan) addCallback(cb func(context.Context) error, exitOpts ...exitC
}

c := &callback{
fn: cb,
name: callerLocation(3),
fn: cb,
}

for _, opt := range exitOpts {
Expand Down Expand Up @@ -255,7 +249,10 @@ func (l *Exitplan) exit() {
}

if err := cb.fn(execCtx); err != nil {
l.handleExitError(cb.errorBehaviour, err)
l.handleExitError(cb.errorBehaviour, &CallbackErr{
Name: cb.name,
Err: err,
})
}
}(cb)
}
Expand All @@ -279,7 +276,10 @@ func (l *Exitplan) exit() {
}

if err := cb.fn(execCtx); err != nil {
l.handleExitError(cb.errorBehaviour, err)
l.handleExitError(cb.errorBehaviour, &CallbackErr{
Name: cb.name,
Err: err,
})
}

cancel()
Expand Down
52 changes: 48 additions & 4 deletions exitplan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"reflect"
"slices"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -58,7 +59,7 @@ func TestExitCallbacks(t *testing.T) {
}, exitplan.Async)

go func() {
<-l.StartingContext().Done()
<-l.Started()
l.Exit(errUnexpected)
}()

Expand Down Expand Up @@ -115,7 +116,7 @@ func TestPanic(t *testing.T) {
}, exitplan.PanicOnError)

go func() {
<-l.StartingContext().Done()
<-l.Started()
l.Exit(errUnexpected)
}()

Expand Down Expand Up @@ -143,7 +144,7 @@ func TestTeardownTimeout(t *testing.T) {
})

go func() {
<-l.StartingContext().Done()
<-l.Started()
l.Exit(errUnexpected)
}()

Expand Down Expand Up @@ -173,7 +174,7 @@ func TestOnExitTimeout(t *testing.T) {
}, exitplan.Timeout(timeout))

go func() {
<-l.StartingContext().Done()
<-l.Started()
l.Exit(errUnexpected)
}()

Expand All @@ -191,3 +192,46 @@ func TestOnExitTimeout(t *testing.T) {
t.Error("callback was called")
}
}

func TestCallbackName(t *testing.T) {
t.Parallel()

mu := sync.Mutex{}
names := make([]string, 0)

l := exitplan.New(
exitplan.WithExitError(func(err error) {
var exErr *exitplan.CallbackErr
if errors.As(err, &exErr) {
mu.Lock()
names = append(names, exErr.Name)
mu.Unlock()
}
}),
)

l.OnExitWithContextError(func(ctx context.Context) error {
return errors.New("test error")
}, exitplan.Name("cb1"), exitplan.Async)

l.OnExitWithContextError(func(ctx context.Context) error {
return errors.New("test error")
}, exitplan.Name("cb2"))

go func() {
<-l.Started()
l.Exit(errUnexpected)
}()

if err := l.Run(); !errors.Is(err, errUnexpected) {
t.Errorf("expected %q, got: %q", errUnexpected, err)
}

if len(names) != 2 {
t.Errorf("expected 2 callback calls got %d", len(names))
}

if !reflect.DeepEqual(names, []string{"cb2", "cb1"}) {
t.Errorf("expected names to have cb1 callback, got: %v", names)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module github.com/struct0x/exitplan

go 1.24
go 1.25
Empty file added go.sum
Empty file.
47 changes: 47 additions & 0 deletions signal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//go:build !windows

package exitplan_test

import (
"errors"
"syscall"
"testing"
"time"

"github.com/struct0x/exitplan"
)

func TestSignalHandling(t *testing.T) {
t.Parallel()

ex := exitplan.New(
exitplan.WithSignal(syscall.SIGUSR1),
)

callbackRan := false
ex.OnExit(func() {
callbackRan = true
})

done := make(chan error, 1)
go func() {
done <- ex.Run()
}()

if err := syscall.Kill(syscall.Getpid(), syscall.SIGUSR1); err != nil {
t.Errorf("error calling Kill: %v", err)
}

select {
case err := <-done:
if !errors.Is(err, exitplan.ErrSignaled) {
t.Errorf("expected ErrSignaled, got %v", err)
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for shutdown")
}

if !callbackRan {
t.Error("callback should have run")
}
}
Loading