From cd0fa1d8793f8da129ee65fa49a5937c6c623e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Dejnek?= Date: Tue, 27 Jan 2026 16:58:34 +0100 Subject: [PATCH 1/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) --- README.md | 73 +++++++++++++++++++++++++++++++++++------------- callbacks.go | 32 +++++++++++++++++++++ exitplan.go | 34 +++++++++++----------- exitplan_test.go | 49 +++++++++++++++++++++++++++++--- go.mod | 2 +- go.sum | 0 signal_test.go | 47 +++++++++++++++++++++++++++++++ 7 files changed, 196 insertions(+), 41 deletions(-) create mode 100644 go.sum create mode 100644 signal_test.go diff --git a/README.md b/README.md index 42c2626..df04a2c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # 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) @@ -6,12 +7,12 @@ A Go library for managing the lifecycle of an application with graceful shutdown ## 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 @@ -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. @@ -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) { @@ -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): diff --git a/callbacks.go b/callbacks.go index c7f0d56..0f0c050 100644 --- a/callbacks.go +++ b/callbacks.go @@ -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. @@ -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 ( @@ -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) +} diff --git a/exitplan.go b/exitplan.go index d502ca0..ad7d9a2 100644 --- a/exitplan.go +++ b/exitplan.go @@ -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. @@ -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 } @@ -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 { @@ -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) } @@ -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() diff --git a/exitplan_test.go b/exitplan_test.go index 5954cad..ccac6af 100644 --- a/exitplan_test.go +++ b/exitplan_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "reflect" "slices" "sync" "sync/atomic" @@ -58,7 +59,7 @@ func TestExitCallbacks(t *testing.T) { }, exitplan.Async) go func() { - <-l.StartingContext().Done() + <-l.Started() l.Exit(errUnexpected) }() @@ -115,7 +116,7 @@ func TestPanic(t *testing.T) { }, exitplan.PanicOnError) go func() { - <-l.StartingContext().Done() + <-l.Started() l.Exit(errUnexpected) }() @@ -143,7 +144,7 @@ func TestTeardownTimeout(t *testing.T) { }) go func() { - <-l.StartingContext().Done() + <-l.Started() l.Exit(errUnexpected) }() @@ -173,7 +174,7 @@ func TestOnExitTimeout(t *testing.T) { }, exitplan.Timeout(timeout)) go func() { - <-l.StartingContext().Done() + <-l.Started() l.Exit(errUnexpected) }() @@ -191,3 +192,43 @@ func TestOnExitTimeout(t *testing.T) { t.Error("callback was called") } } + +func TestCallbackName(t *testing.T) { + t.Parallel() + + names := make([]string, 0) + + l := exitplan.New( + exitplan.WithExitError(func(err error) { + var exErr *exitplan.CallbackErr + if errors.As(err, &exErr) { + names = append(names, exErr.Name) + } + }), + ) + + 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) + } +} diff --git a/go.mod b/go.mod index 76b5896..28ba5e3 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/struct0x/exitplan -go 1.24 +go 1.25 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/signal_test.go b/signal_test.go new file mode 100644 index 0000000..e642a25 --- /dev/null +++ b/signal_test.go @@ -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") + } +} From ccb108c41d7bdf853ba767e8b7c9fc833216eba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Dejnek?= Date: Tue, 27 Jan 2026 17:01:56 +0100 Subject: [PATCH 2/2] fix: race in tests --- exitplan_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/exitplan_test.go b/exitplan_test.go index ccac6af..d6a9649 100644 --- a/exitplan_test.go +++ b/exitplan_test.go @@ -196,13 +196,16 @@ func TestOnExitTimeout(t *testing.T) { 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() } }), )