Skip to content

Commit c641f44

Browse files
authored
refactor: simplify API by replacing StartingContext with Started signal (#2)
* refactor: simplify API by replacing StartingContext with Started signal - Remove StartingContext() to prevent misuse with long-running operations - Add Started() <-chan struct{} for signaling when Run() is called - Add CallbackErr type for structured error reporting with callback names - Add Name() option for explicit callback identification - Add callerLocation() for automatic callback naming (file:line) - Move signal test to separate file with //go:build !windows - Update documentation to reflect two-phase lifecycle (running, teardown) This is a breaking change that simplifies the mental model: - Context() is the app lifetime context - TeardownContext() bounds cleanup time - Started() signals readiness (useful for health probes)
1 parent 713a889 commit c641f44

File tree

7 files changed

+199
-41
lines changed

7 files changed

+199
-41
lines changed

README.md

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
# Exitplan
2+
23
[![Go Reference](https://pkg.go.dev/badge/github.com/struct0x/exitplan.svg)](https://pkg.go.dev/github.com/struct0x/exitplan)
34
![Coverage](https://img.shields.io/badge/Coverage-83.2%25-brightgreen)
45

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

78
## Overview
89

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

1213
Key features include:
1314

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

2829
## Lifecycle Phases
2930

30-
Exitplan splits application lifetime into three phases, each with its own context:
31-
32-
- **Starting**: before `Run()` begins. Use `StartingContext()` for initialization.
33-
It is canceled immediately when `Run()` starts.
31+
Exitplan manages two lifecycle phases:
3432

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

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

40+
Use
41+
`Started()` to receive a signal when the application enters the running phase.
42+
This is useful for readiness probes or coordinating dependent services.
43+
44+
### Startup Timeout
45+
46+
Use `WithStartupTimeout()` to detect stuck initialization:
47+
48+
```go
49+
package main
50+
51+
import (
52+
"time"
53+
54+
"github.com/struct0x/exitplan"
55+
)
56+
57+
func main() {
58+
_ = exitplan.New(
59+
exitplan.WithStartupTimeout(10 * time.Second),
60+
)
61+
62+
// If Run() isn't called within 10 seconds,
63+
// Context() is canceled and teardown begins
64+
}
65+
66+
```
67+
68+
This is useful when initialization depends on external services that might hang.
69+
4170
### Callback ordering
4271

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

@@ -107,13 +137,18 @@ func main() {
107137
}),
108138
)
109139

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

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

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

147182
// Get the running context to use in your application
148-
runningCtx := ex.Context()
183+
ctx := ex.Context()
149184

150185
// Start a worker that respects the application lifecycle
151186
workerDone := make(chan struct{})
152187
go func() {
153188
for {
154189
select {
155-
case <-runningCtx.Done():
190+
case <-ctx.Done():
156191
fmt.Println("Worker shutting down...")
157-
time.Sleep(100 * time.Millisecond) // Simulate some work
192+
time.Sleep(100 * time.Millisecond) // Simulate some teardown work
158193
close(workerDone)
159194
return
160195
case <-time.After(1 * time.Second):

callbacks.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,25 @@ package exitplan
22

33
import (
44
"context"
5+
"fmt"
6+
"path/filepath"
7+
"runtime"
58
"time"
69
)
710

11+
type CallbackErr struct {
12+
Name string
13+
Err error
14+
}
15+
16+
func (e *CallbackErr) Error() string {
17+
return fmt.Sprintf("callback %s: %v", e.Name, e.Err)
18+
}
19+
20+
func (e *CallbackErr) Unwrap() error {
21+
return e.Err
22+
}
23+
824
type exitCallbackOpt func(*callback)
925

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

45+
// Name sets callback name, used for identification.
46+
func Name(name string) exitCallbackOpt {
47+
return func(c *callback) {
48+
c.name = name
49+
}
50+
}
51+
2952
type executeBehaviour int
3053

3154
const (
@@ -41,8 +64,17 @@ const (
4164
)
4265

4366
type callback struct {
67+
name string
4468
executeBehaviour executeBehaviour
4569
errorBehaviour exitBehaviour
4670
timeout time.Duration
4771
fn func(context.Context) error
4872
}
73+
74+
func callerLocation(skip int) string {
75+
_, file, line, ok := runtime.Caller(skip)
76+
if !ok {
77+
return "unknown"
78+
}
79+
return fmt.Sprintf("%s:%d", filepath.Base(file), line)
80+
}

exitplan.go

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
/*
22
Package exitplan implements a simple mechanism for managing a lifetime of an application.
33
It provides a way to register functions that will be called when the application is about to exit.
4-
It distinguishes between starting, running and teardown phases.
5-
6-
The application is considered to be starting before calling Exitplan.Run().
7-
You can use Exitplan.StartingContext() to get a context that can be used to control the startup phase.
8-
Starting context is canceled when the startup phase is over.
94
105
The application is considered to be running after calling Exitplan.Run() and before calling Exitplan.Exit().
11-
You can use Exitplan.Context() to get a context that can be used to control the running phase.
12-
It is canceled when the application is about to exit.
6+
Use Exitplan.Started() to receive a signal when the application enters the running phase.
7+
Use Exitplan.Context() to get a context bound to the application lifetime.
138
149
The application is considered to be tearing down after calling Exitplan.Exit().
1510
You can use Exitplan.TeardownContext() to get a context that can be used to control the teardown phase.
@@ -107,15 +102,13 @@ func (l *Exitplan) start() {
107102
l.startingCancel = cancel
108103
}
109104

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

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

182175
c := &callback{
183-
fn: cb,
176+
name: callerLocation(3),
177+
fn: cb,
184178
}
185179

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

257251
if err := cb.fn(execCtx); err != nil {
258-
l.handleExitError(cb.errorBehaviour, err)
252+
l.handleExitError(cb.errorBehaviour, &CallbackErr{
253+
Name: cb.name,
254+
Err: err,
255+
})
259256
}
260257
}(cb)
261258
}
@@ -279,7 +276,10 @@ func (l *Exitplan) exit() {
279276
}
280277

281278
if err := cb.fn(execCtx); err != nil {
282-
l.handleExitError(cb.errorBehaviour, err)
279+
l.handleExitError(cb.errorBehaviour, &CallbackErr{
280+
Name: cb.name,
281+
Err: err,
282+
})
283283
}
284284

285285
cancel()

exitplan_test.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"reflect"
78
"slices"
89
"sync"
910
"sync/atomic"
@@ -58,7 +59,7 @@ func TestExitCallbacks(t *testing.T) {
5859
}, exitplan.Async)
5960

6061
go func() {
61-
<-l.StartingContext().Done()
62+
<-l.Started()
6263
l.Exit(errUnexpected)
6364
}()
6465

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

117118
go func() {
118-
<-l.StartingContext().Done()
119+
<-l.Started()
119120
l.Exit(errUnexpected)
120121
}()
121122

@@ -143,7 +144,7 @@ func TestTeardownTimeout(t *testing.T) {
143144
})
144145

145146
go func() {
146-
<-l.StartingContext().Done()
147+
<-l.Started()
147148
l.Exit(errUnexpected)
148149
}()
149150

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

175176
go func() {
176-
<-l.StartingContext().Done()
177+
<-l.Started()
177178
l.Exit(errUnexpected)
178179
}()
179180

@@ -191,3 +192,46 @@ func TestOnExitTimeout(t *testing.T) {
191192
t.Error("callback was called")
192193
}
193194
}
195+
196+
func TestCallbackName(t *testing.T) {
197+
t.Parallel()
198+
199+
mu := sync.Mutex{}
200+
names := make([]string, 0)
201+
202+
l := exitplan.New(
203+
exitplan.WithExitError(func(err error) {
204+
var exErr *exitplan.CallbackErr
205+
if errors.As(err, &exErr) {
206+
mu.Lock()
207+
names = append(names, exErr.Name)
208+
mu.Unlock()
209+
}
210+
}),
211+
)
212+
213+
l.OnExitWithContextError(func(ctx context.Context) error {
214+
return errors.New("test error")
215+
}, exitplan.Name("cb1"), exitplan.Async)
216+
217+
l.OnExitWithContextError(func(ctx context.Context) error {
218+
return errors.New("test error")
219+
}, exitplan.Name("cb2"))
220+
221+
go func() {
222+
<-l.Started()
223+
l.Exit(errUnexpected)
224+
}()
225+
226+
if err := l.Run(); !errors.Is(err, errUnexpected) {
227+
t.Errorf("expected %q, got: %q", errUnexpected, err)
228+
}
229+
230+
if len(names) != 2 {
231+
t.Errorf("expected 2 callback calls got %d", len(names))
232+
}
233+
234+
if !reflect.DeepEqual(names, []string{"cb2", "cb1"}) {
235+
t.Errorf("expected names to have cb1 callback, got: %v", names)
236+
}
237+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module github.com/struct0x/exitplan
22

3-
go 1.24
3+
go 1.25

go.sum

Whitespace-only changes.

signal_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//go:build !windows
2+
3+
package exitplan_test
4+
5+
import (
6+
"errors"
7+
"syscall"
8+
"testing"
9+
"time"
10+
11+
"github.com/struct0x/exitplan"
12+
)
13+
14+
func TestSignalHandling(t *testing.T) {
15+
t.Parallel()
16+
17+
ex := exitplan.New(
18+
exitplan.WithSignal(syscall.SIGUSR1),
19+
)
20+
21+
callbackRan := false
22+
ex.OnExit(func() {
23+
callbackRan = true
24+
})
25+
26+
done := make(chan error, 1)
27+
go func() {
28+
done <- ex.Run()
29+
}()
30+
31+
if err := syscall.Kill(syscall.Getpid(), syscall.SIGUSR1); err != nil {
32+
t.Errorf("error calling Kill: %v", err)
33+
}
34+
35+
select {
36+
case err := <-done:
37+
if !errors.Is(err, exitplan.ErrSignaled) {
38+
t.Errorf("expected ErrSignaled, got %v", err)
39+
}
40+
case <-time.After(time.Second):
41+
t.Fatal("timeout waiting for shutdown")
42+
}
43+
44+
if !callbackRan {
45+
t.Error("callback should have run")
46+
}
47+
}

0 commit comments

Comments
 (0)