Skip to content

Commit ea0b5db

Browse files
committed
feat: make Exit() blocking with early exit support and lifecycle signals
Allow Exit() to be called before Run() to short-circuit startup and unwind callbacks. Exit() now blocks until teardown completes and returns the exit cause, enabling the pattern: return lc.Exit(err). Add Started(), Stopping(), and Completed() lifecycle signals. Fix PanicOnError for async callbacks and race conditions in callback registration during concurrent teardown.
1 parent 12e44a5 commit ea0b5db

File tree

4 files changed

+423
-65
lines changed

4 files changed

+423
-65
lines changed

README.md

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,23 @@ Exitplan manages two lifecycle phases:
3535
`Context()` for workers and other long-running tasks.
3636
It is canceled as soon as shutdown begins (via `Exit()`, signal, or startup timeout).
3737

38-
- **Teardown**: after `Exit()` is called. Use `TeardownContext()` in shutdown callbacks.
38+
- **Teardown**: calling `Exit(reason or nil)` starts the teardown.
39+
It blocks until all registered callbacks are complete.
40+
Use `TeardownContext()` in shutdown callbacks.
3941
It is canceled when the global teardown timeout elapses.
4042

43+
> [!NOTE]
44+
> Calling `Exit()` before `Run()` starts the teardown immediately.
45+
> If no teardown timeout is set and a callback hangs, Exit() will block indefinitely.
46+
4147
Use
4248
`Started()` to receive a signal when the application enters the running phase.
4349
This is useful for readiness probes or coordinating dependent services.
4450

51+
Use `Stopping()` to receive a signal when the application enters the teardown phase.
52+
53+
Use `Completed()` to receive a signal when the teardown phase completes.
54+
4555
### Startup Timeout
4656

4757
Use `WithStartupTimeout()` to detect stuck initialization:
@@ -140,8 +150,12 @@ func main() {
140150

141151
// Signal readiness when Run() starts
142152
go func() {
143-
<-ex.Started()
144-
fmt.Println("Application is now running and ready")
153+
select {
154+
case <-ex.Started():
155+
fmt.Println("Application is now running")
156+
case <-ex.Stopping():
157+
fmt.Println("Application is shutting down before it was ready")
158+
}
145159
// e.g., signal readiness probe, notify dependent services
146160
}()
147161

@@ -214,6 +228,49 @@ func main() {
214228

215229
```
216230

231+
### Early Exit During Setup
232+
233+
If initialization fails, use `Exit()` to short-circuit and unwind callbacks without calling `Run()`:
234+
235+
```go
236+
package main
237+
238+
import (
239+
"fmt"
240+
"syscall"
241+
"time"
242+
243+
"github.com/struct0x/exitplan"
244+
)
245+
246+
func run() error {
247+
ex := exitplan.New(
248+
exitplan.WithSignal(syscall.SIGINT, syscall.SIGTERM),
249+
exitplan.WithTeardownTimeout(5*time.Second),
250+
)
251+
252+
db, err := connectDB()
253+
if err != nil {
254+
return ex.Exit(err) // teardown runs, then returns err
255+
}
256+
ex.OnExit(func() { db.Close() })
257+
258+
cache, err := connectCache()
259+
if err != nil {
260+
return ex.Exit(err) // db.Close() runs, then returns err
261+
}
262+
ex.OnExit(func() { cache.Close() })
263+
264+
return ex.Run()
265+
}
266+
267+
func main() {
268+
if err := run(); err != nil {
269+
fmt.Println(err)
270+
}
271+
}
272+
```
273+
217274
## License
218275

219276
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

exitplan.go

Lines changed: 101 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ It provides a way to register functions that will be called when the application
44
55
The application is considered to be running after calling Exitplan.Run() and before calling Exitplan.Exit().
66
Use Exitplan.Started() to receive a signal when the application enters the running phase.
7+
Use Exitplan.Stopping() to receive a signal when the application enters the teardown phase.
78
Use Exitplan.Context() to get a context bound to the application lifetime.
9+
Use Exitplan.Completed() to receive a signal when teardown is completed.
810
911
The application is considered to be tearing down after calling Exitplan.Exit().
1012
You can use Exitplan.TeardownContext() to get a context that can be used to control the teardown phase.
1113
It is canceled when the teardown timeout is reached.
1214
1315
Ordering of shutdown callbacks
1416
Exitplan executes registered exit callbacks in LIFO order (last registered, first executed).
15-
Async only offloads the execution to a goroutine, but Exitplan still waits for all callbacks up to the
17+
Async only offloads the execution to a goroutine, but the Exitplan still waits for all callbacks up to the
1618
teardown timeout.
1719
*/
1820
package exitplan
@@ -43,8 +45,7 @@ type Exitplan struct {
4345
runningCtx context.Context
4446
runningCancel context.CancelCauseFunc
4547

46-
startingCtx context.Context
47-
startingCancel context.CancelFunc
48+
started chan struct{}
4849
startingTimeout time.Duration
4950

5051
teardownCtx context.Context
@@ -53,6 +54,10 @@ type Exitplan struct {
5354

5455
callbacks []*callback
5556

57+
panicValue any
58+
panicOnce sync.Once
59+
ranViaRun bool
60+
5661
errorHandler func(error)
5762
callbacksMu *sync.Mutex
5863
}
@@ -64,6 +69,7 @@ func New(opts ...opt) *Exitplan {
6469
teardownCtx, teardownCancel := context.WithCancel(context.Background())
6570
l := &Exitplan{
6671
die: make(chan struct{}),
72+
started: make(chan struct{}),
6773
callbacksMu: &sync.Mutex{},
6874
callbacks: make([]*callback, 0),
6975

@@ -85,26 +91,57 @@ func New(opts ...opt) *Exitplan {
8591
func (l *Exitplan) start() {
8692
l.phase.Store(int32(phaseStarting))
8793

88-
ctx, cancel := context.WithCancel(context.Background())
8994
if l.startingTimeout > 0 {
90-
ctx, cancel = context.WithTimeout(ctx, l.startingTimeout)
95+
ctx, cancel := context.WithTimeout(context.Background(), l.startingTimeout)
9196
go func() {
97+
defer cancel()
9298
<-ctx.Done()
9399

94-
if l.phase.Load() == int32(phaseStarting) {
100+
if l.phase.CompareAndSwap(int32(phaseStarting), int32(phaseTeardown)) {
95101
l.runningCancel(ErrStartupTimeout)
96102
close(l.die)
97103
}
98104
}()
99105
}
100106

101-
l.startingCtx = ctx
102-
l.startingCancel = cancel
107+
go func() {
108+
<-l.die
109+
110+
go func() {
111+
if l.teardownTimeout > 0 {
112+
<-time.After(l.teardownTimeout)
113+
l.teardownCancel()
114+
}
115+
}()
116+
117+
defer l.teardownCancel()
118+
defer func() {
119+
if r := recover(); r != nil {
120+
l.capturePanic(r)
121+
}
122+
}()
123+
124+
l.exit()
125+
}()
103126
}
104127

105-
// Started returns a channel that is closed after Run is called.
128+
// Started returns a channel that is closed after Run is called and the application enters the running phase.
129+
// It will never fire if Exit is called before Run, or if the startup timeout expires.
106130
func (l *Exitplan) Started() <-chan struct{} {
107-
return l.startingCtx.Done()
131+
return l.started
132+
}
133+
134+
// Completed returns a channel closed after either all callback or teardown context is completed.
135+
// This signal means that now the application can exit.
136+
func (l *Exitplan) Completed() <-chan struct{} {
137+
return l.teardownCtx.Done()
138+
}
139+
140+
// Stopping returns a channel closed when the teardown phase starts.
141+
// This is equivalent to calling Exitplan.Context().Done().
142+
// It's fired before the application enters the teardown phase by calling Exit.
143+
func (l *Exitplan) Stopping() <-chan struct{} {
144+
return l.runningCtx.Done()
108145
}
109146

110147
// Context returns a main context. It will be canceled when the application is about to exit.
@@ -121,7 +158,7 @@ func (l *Exitplan) TeardownContext() context.Context {
121158
}
122159

123160
// OnExit registers a callback that will be called when the application is about to exit.
124-
// Has no effect after calling Exitplan.Run().
161+
// Has no effect after calling Exitplan.Run() or Exitplan.Exit().
125162
// Use exitplan.Async option to execute the callback in a separate goroutine.
126163
// exitplan.PanicOnError has no effect on this function.
127164
// See also: OnExitWithError, OnExitWithContext, OnExitWithContextError.
@@ -135,7 +172,7 @@ func (l *Exitplan) OnExit(callback func(), exitOpts ...exitCallbackOpt) {
135172
}
136173

137174
// OnExitWithError registers a callback that will be called when the application is about to exit.
138-
// Has no effect after calling Exitplan.Run().
175+
// Has no effect after calling Exitplan.Run() or Exitplan.Exit().
139176
// The callback can return an error that will be passed to the error handler.
140177
// Use exitplan.Async option to execute the callback in a separate goroutine.
141178
// Use exitplan.PanicOnError to panic with the error returned by the callback.
@@ -146,7 +183,7 @@ func (l *Exitplan) OnExitWithError(callback func() error, exitOpts ...exitCallba
146183
}
147184

148185
// OnExitWithContext registers a callback that will be called when the application is about to exit.
149-
// Has no effect after calling Exitplan.Run().
186+
// Has no effect after calling Exitplan.Run() or Exitplan.Exit().
150187
// The callback will receive a context that will be canceled after the teardown timeout.
151188
// Use exitplan.Async option to execute the callback in a separate goroutine.
152189
// exitplan.PanicOnError has no effect on this function.
@@ -158,7 +195,7 @@ func (l *Exitplan) OnExitWithContext(callback func(context.Context), exitOpts ..
158195
}
159196

160197
// OnExitWithContextError registers a callback that will be called when the application is about to exit.
161-
// Has no effect after calling Exitplan.Run().
198+
// Has no effect after calling Exitplan.Run() or Exitplan.Exit().
162199
// The callback will receive a context that will be canceled after the teardown timeout.
163200
// The callback can return an error that will be passed to the error handler.
164201
// Use exitplan.Async option to execute the callback in a separate goroutine.
@@ -168,10 +205,6 @@ func (l *Exitplan) OnExitWithContextError(callback func(context.Context) error,
168205
}
169206

170207
func (l *Exitplan) addCallback(cb func(context.Context) error, exitOpts ...exitCallbackOpt) {
171-
if l.phase.Load() != int32(phaseStarting) {
172-
return
173-
}
174-
175208
c := &callback{
176209
name: callerLocation(3),
177210
fn: cb,
@@ -183,63 +216,84 @@ func (l *Exitplan) addCallback(cb func(context.Context) error, exitOpts ...exitC
183216

184217
l.callbacksMu.Lock()
185218
defer l.callbacksMu.Unlock()
219+
220+
if l.phase.Load() != int32(phaseStarting) {
221+
return
222+
}
223+
186224
l.callbacks = append(l.callbacks, c)
187225
}
188226

189-
// Exit stops the application. It will cause the application to exit with the specified reason.
227+
// Exit stops the application and waits for all registered callbacks to complete.
228+
// It returns the provided reason, enabling the pattern: return lc.Exit(err).
190229
// If the reason is nil, ErrGracefulShutdown will be used.
191-
// Multiple calls to Exit are safe, but only the first one will have an effect.
192-
func (l *Exitplan) Exit(reason error) {
230+
// Exit can be called before or after Run.
231+
// Multiple calls to Exit are safe; all will block until teardown completes.
232+
func (l *Exitplan) Exit(reason error) error {
193233
if reason == nil {
194234
reason = ErrGracefulShutdown
195235
}
196236

197-
if !l.phase.CompareAndSwap(int32(phaseRunning), int32(phaseTeardown)) {
198-
return
237+
if l.phase.CompareAndSwap(int32(phaseRunning), int32(phaseTeardown)) ||
238+
l.phase.CompareAndSwap(int32(phaseStarting), int32(phaseTeardown)) {
239+
l.runningCancel(reason)
240+
close(l.die)
199241
}
200242

201-
l.runningCancel(reason)
202-
close(l.die)
243+
<-l.teardownCtx.Done()
244+
245+
if !l.ranViaRun && l.panicValue != nil {
246+
panic(l.panicValue)
247+
}
248+
249+
return context.Cause(l.runningCtx)
203250
}
204251

205252
// Run starts the application. It will block until the application is stopped by calling Exit.
206253
// It will also block until all the registered callbacks are executed.
207254
// If the teardown timeout is set, it will be used to cancel the context passed to the callbacks.
255+
// If Exit was called before Run, Run will wait for teardown to complete and return the exit cause.
208256
// Returns the error that caused the application to stop.
209257
func (l *Exitplan) Run() (exitCause error) {
210-
if !l.phase.CompareAndSwap(int32(phaseStarting), int32(phaseRunning)) {
211-
panic("Exitplan.Run() called after Exitplan.Exit()")
212-
}
258+
l.ranViaRun = true
213259

214-
l.startingCancel()
215-
216-
<-l.die
260+
// If Exit() was already called, skip the starting→running transition.
261+
if l.phase.CompareAndSwap(int32(phaseStarting), int32(phaseRunning)) {
262+
close(l.started)
263+
}
217264

218-
go func() {
219-
if l.teardownTimeout > 0 {
220-
<-time.After(l.teardownTimeout)
221-
l.teardownCancel()
222-
}
223-
}()
265+
<-l.teardownCtx.Done()
224266

225-
l.exit()
267+
if l.panicValue != nil {
268+
panic(l.panicValue)
269+
}
226270

227271
return context.Cause(l.runningCtx)
228272
}
229273

230274
func (l *Exitplan) exit() {
231275
ctx := l.TeardownContext()
232276

277+
l.callbacksMu.Lock()
278+
callbacks := make([]*callback, len(l.callbacks))
279+
copy(callbacks, l.callbacks)
280+
l.callbacksMu.Unlock()
281+
233282
wg := sync.WaitGroup{}
234283

235-
for _, cb := range l.callbacks {
284+
for _, cb := range callbacks {
236285
if cb.executeBehaviour != executeAsync {
237286
continue
238287
}
239288

240289
wg.Add(1)
241290
go func(cb *callback) {
242291
defer wg.Done()
292+
defer func() {
293+
if r := recover(); r != nil {
294+
l.capturePanic(r)
295+
}
296+
}()
243297

244298
execCtx := ctx
245299
if cb.timeout > 0 {
@@ -257,8 +311,8 @@ func (l *Exitplan) exit() {
257311
}(cb)
258312
}
259313

260-
for i := len(l.callbacks) - 1; i >= 0; i-- {
261-
cb := l.callbacks[i]
314+
for i := len(callbacks) - 1; i >= 0; i-- {
315+
cb := callbacks[i]
262316
if cb.executeBehaviour != executeSync {
263317
continue
264318
}
@@ -303,6 +357,12 @@ func (l *Exitplan) handleExitError(errBehaviour exitBehaviour, err error) {
303357
}
304358
}
305359

360+
func (l *Exitplan) capturePanic(r any) {
361+
l.panicOnce.Do(func() {
362+
l.panicValue = r
363+
})
364+
}
365+
306366
func callbackWithContext(ctx context.Context, callback func() error) error {
307367
errChan := make(chan error)
308368
go func() {

0 commit comments

Comments
 (0)