Skip to content

Commit c86a871

Browse files
author
Dean Karn
authored
Add common Go App shutdown logic via Context (#42)
Added new `appext` package to start adding application primitives that are common building blocks of a go application and not confined to using a single packages dependencies.
1 parent 1b30e02 commit c86a871

File tree

4 files changed

+191
-1
lines changed

4 files changed

+191
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
## [5.24.0] - 2024-01-21
10+
### Added
11+
- `appext` package for application level helpers. Specifically added setting up os signal trapping and cancellation of context.Context.
12+
913
## [5.23.0] - 2024-01-14
1014
### Added
1115
- `And` and `AndThen` functions to `Option` & `Result` types.
@@ -87,6 +91,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8791
- Added `timext.NanoTime` for fast low level monotonic time with nanosecond precision.
8892

8993
[Unreleased]: https://github.com/go-playground/pkg/compare/v5.23.0...HEAD
94+
[5.24.0]: https://github.com/go-playground/pkg/compare/v5.23.0..v5.24.0
9095
[5.23.0]: https://github.com/go-playground/pkg/compare/v5.22.0..v5.23.0
9196
[5.22.0]: https://github.com/go-playground/pkg/compare/v5.21.3..v5.22.0
9297
[5.21.3]: https://github.com/go-playground/pkg/compare/v5.21.2..v5.21.3

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# pkg
22

3-
![Project status](https://img.shields.io/badge/version-5.22.0-green.svg)
3+
![Project status](https://img.shields.io/badge/version-5.24.0-green.svg)
44
[![Lint & Test](https://github.com/go-playground/pkg/actions/workflows/go.yml/badge.svg)](https://github.com/go-playground/pkg/actions/workflows/go.yml)
55
[![Coverage Status](https://coveralls.io/repos/github/go-playground/pkg/badge.svg?branch=master)](https://coveralls.io/github/go-playground/pkg?branch=master)
66
[![GoDoc](https://godoc.org/github.com/go-playground/pkg?status.svg)](https://pkg.go.dev/mod/github.com/go-playground/pkg/v5)

app/context.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package appext
2+
3+
import (
4+
"context"
5+
"log"
6+
"os"
7+
"os/signal"
8+
"syscall"
9+
"time"
10+
)
11+
12+
type contextBuilder struct {
13+
signals []os.Signal
14+
timeout time.Duration
15+
exitFn func(int)
16+
forceExit bool
17+
}
18+
19+
// Context returns a new context builder, with sane defaults, that can be overridden. Calling `Build()` finalizes
20+
// the new desired context and returns the configured `context.Context`.
21+
func Context() *contextBuilder {
22+
return &contextBuilder{
23+
signals: []os.Signal{
24+
os.Interrupt,
25+
syscall.SIGTERM,
26+
syscall.SIGQUIT,
27+
},
28+
timeout: 30 * time.Second,
29+
forceExit: true,
30+
exitFn: os.Exit,
31+
}
32+
}
33+
34+
// Signals sets the signals to listen for. Defaults to `os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT`.
35+
func (c *contextBuilder) Signals(signals ...os.Signal) *contextBuilder {
36+
c.signals = signals
37+
return c
38+
}
39+
40+
// Timeout sets the timeout for graceful shutdown before forcing the issue exiting with exit code 1.
41+
// Defaults to 30 seconds.
42+
//
43+
// A timeout of <= 0, not recommended, disables the timeout and will wait forever for a seconds signal or application
44+
// shuts down.
45+
func (c *contextBuilder) Timeout(timeout time.Duration) *contextBuilder {
46+
c.timeout = timeout
47+
return c
48+
}
49+
50+
// ForceExit sets whether to force terminate ungracefully upon receipt of a second signal. Defaults to true.
51+
func (c *contextBuilder) ForceExit(forceExit bool) *contextBuilder {
52+
c.forceExit = forceExit
53+
return c
54+
}
55+
56+
// ExitFn sets the exit function to use. Defaults to `os.Exit`.
57+
//
58+
// This is used in the unit tests but can be used to intercept the exit call and do something else as needed also.
59+
func (c *contextBuilder) ExitFn(exitFn func(int)) *contextBuilder {
60+
c.exitFn = exitFn
61+
return c
62+
}
63+
64+
// Build finalizes the context builder and returns the configured `context.Context`.
65+
//
66+
// This will spawn another goroutine listening for the configured signals and will cancel the context when received with
67+
// the configured settings.
68+
func (c *contextBuilder) Build() context.Context {
69+
var sig = make(chan os.Signal, 1)
70+
signal.Notify(sig, c.signals...)
71+
72+
ctx, cancel := context.WithCancel(context.Background())
73+
74+
go listen(sig, cancel, c.exitFn, c.timeout, c.forceExit)
75+
76+
return ctx
77+
}
78+
79+
func listen(sig <-chan os.Signal, cancel context.CancelFunc, exitFn func(int), timeout time.Duration, forceExit bool) {
80+
s := <-sig
81+
cancel()
82+
log.Printf("received shutdown signal %q\n", s)
83+
84+
if timeout > 0 {
85+
select {
86+
case s := <-sig:
87+
if forceExit {
88+
log.Printf("received second shutdown signal %q, forcing exit\n", s)
89+
exitFn(1)
90+
}
91+
case <-time.After(timeout):
92+
log.Printf("timeout of %s reached, forcing exit\n", timeout)
93+
exitFn(1)
94+
}
95+
} else {
96+
s = <-sig
97+
if forceExit {
98+
log.Printf("received second shutdown signal %q, forcing exit\n", s)
99+
exitFn(1)
100+
}
101+
}
102+
}

app/context_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package appext
2+
3+
import (
4+
"context"
5+
. "github.com/go-playground/assert/v2"
6+
"os"
7+
"os/signal"
8+
"sync"
9+
"testing"
10+
"time"
11+
)
12+
13+
func TestForceExitWithNoTimeout(t *testing.T) {
14+
var wg sync.WaitGroup
15+
wg.Add(1)
16+
exitFn := func(code int) {
17+
defer wg.Done()
18+
Equal(t, 1, code)
19+
}
20+
21+
c := Context().Timeout(0).ExitFn(exitFn)
22+
23+
// copy of Build for testing
24+
var sig = make(chan os.Signal, 1)
25+
signal.Notify(sig, c.signals...)
26+
27+
ctx, cancel := context.WithCancel(context.Background())
28+
29+
go listen(sig, cancel, c.exitFn, c.timeout, c.forceExit)
30+
31+
sig <- os.Interrupt
32+
sig <- os.Interrupt
33+
wg.Wait()
34+
Equal(t, context.Canceled, ctx.Err())
35+
}
36+
37+
func TestForceExitWithTimeout(t *testing.T) {
38+
var wg sync.WaitGroup
39+
wg.Add(1)
40+
exitFn := func(code int) {
41+
defer wg.Done()
42+
Equal(t, 1, code)
43+
}
44+
45+
c := Context().Timeout(time.Hour).ExitFn(exitFn)
46+
47+
// copy of Build for testing
48+
var sig = make(chan os.Signal, 1)
49+
signal.Notify(sig, c.signals...)
50+
51+
ctx, cancel := context.WithCancel(context.Background())
52+
53+
go listen(sig, cancel, c.exitFn, c.timeout, c.forceExit)
54+
55+
sig <- os.Interrupt
56+
sig <- os.Interrupt
57+
wg.Wait()
58+
Equal(t, context.Canceled, ctx.Err())
59+
}
60+
61+
func TestTimeoutWithNoForceExit(t *testing.T) {
62+
var wg sync.WaitGroup
63+
wg.Add(1)
64+
exitFn := func(code int) {
65+
defer wg.Done()
66+
Equal(t, 1, code)
67+
}
68+
69+
c := Context().Timeout(time.Millisecond * 200).ForceExit(false).ExitFn(exitFn)
70+
71+
// copy of Build for testing
72+
var sig = make(chan os.Signal, 1)
73+
signal.Notify(sig, c.signals...)
74+
75+
ctx, cancel := context.WithCancel(context.Background())
76+
77+
go listen(sig, cancel, c.exitFn, c.timeout, c.forceExit)
78+
79+
// only sending one, timeout must be reached for test to finish
80+
sig <- os.Interrupt
81+
wg.Wait()
82+
Equal(t, context.Canceled, ctx.Err())
83+
}

0 commit comments

Comments
 (0)