Skip to content

Commit 3b94166

Browse files
committed
Add top-level graceful package
1 parent bc670ad commit 3b94166

File tree

5 files changed

+685
-5
lines changed

5 files changed

+685
-5
lines changed

go.mod

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ module github.com/mfridman/cli
22

33
go 1.21.0
44

5-
toolchain go1.23.2
6-
75
require (
86
github.com/mfridman/xflag v0.1.0
9-
github.com/stretchr/testify v1.10.0
7+
github.com/stretchr/testify v1.11.1
108
)
119

1210
require (

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
44
github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE=
55
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
66
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7-
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
8-
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
7+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
8+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
99
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1010
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1111
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

graceful/README.md

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# graceful
2+
3+
`graceful` provides a small helper for running a function with reliable shutdown behavior triggered
4+
by OS signals. It removes the boilerplate required to coordinate context cancellation, timeouts, and
5+
exit codes.
6+
7+
At a high level:
8+
9+
- You supply a function that accepts a `context.Context`
10+
- On the first `SIGINT`/`SIGTERM`, the context is canceled so your function can shut down cleanly
11+
- On a second signal, the process exits immediately (code 130)
12+
- Optional timeouts bound both the run duration and the shutdown period
13+
- Optionally, use `WithImmediateTermination()` to exit immediately on the first signal
14+
15+
This pattern is useful for HTTP servers, workers, CLIs, and batch jobs.
16+
17+
## Installation
18+
19+
```bash
20+
go get github.com/mfridman/cli/graceful
21+
```
22+
23+
## Usage
24+
25+
### Basic example
26+
27+
```go
28+
graceful.Run(func(ctx context.Context) error {
29+
<-ctx.Done() // wait for shutdown signal
30+
return nil
31+
})
32+
```
33+
34+
### HTTP server
35+
36+
```go
37+
mux := http.NewServeMux()
38+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
39+
fmt.Fprintln(w, "hello")
40+
})
41+
42+
server := &http.Server{
43+
Addr: ":8080",
44+
Handler: mux,
45+
}
46+
47+
graceful.Run(
48+
graceful.ListenAndServe(server, 15*time.Second), // HTTP draining period
49+
graceful.WithTerminationTimeout(30*time.Second), // total shutdown limit
50+
)
51+
```
52+
53+
### Batch job with a deadline
54+
55+
```go
56+
graceful.Run(func(ctx context.Context) error {
57+
return processBatch(ctx)
58+
}, graceful.WithRunTimeout(1*time.Hour)) // max 1 hour run time
59+
```
60+
61+
## Options
62+
63+
### `WithRunTimeout(time.Duration)`
64+
65+
Maximum time the run function may execute. Useful for batch jobs or preventing runaway processes.
66+
67+
### `WithTerminationTimeout(time.Duration)`
68+
69+
Maximum time allowed for the process to shut down after the first signal. If exceeded, graceful
70+
exits with code `124`.
71+
72+
### `WithImmediateTermination()`
73+
74+
Disables the graceful shutdown phase. The first signal (`SIGINT`/`SIGTERM`) causes immediate
75+
termination with exit code `130`, without waiting for a second signal. Use this when you need
76+
immediate process termination instead of the default two-signal behavior.
77+
78+
```go
79+
graceful.Run(func(ctx context.Context) error {
80+
return runTask(ctx)
81+
}, graceful.WithImmediateTermination())
82+
```
83+
84+
### `WithLogger(*slog.Logger)`
85+
86+
Uses the provided structured logger for all messages. To disable all logging output, pass a logger
87+
with a discard handler:
88+
89+
```go
90+
graceful.Run(fn, graceful.WithLogger(slog.New(slog.DiscardHandler)))
91+
```
92+
93+
### `WithStderr(io.Writer)`
94+
95+
Redirects stderr output when no logger is used.
96+
97+
## Exit Codes
98+
99+
- `0` — success
100+
- `1` — run function returned an error
101+
- `124` — shutdown timeout exceeded
102+
- `130` — forced shutdown (second signal or immediate termination)
103+
104+
## Signals
105+
106+
- Unix: `SIGINT`, `SIGTERM`
107+
- Windows: `os.Interrupt`
108+
109+
The first signal triggers context cancellation; the second forces termination.
110+
111+
## Gotchas
112+
113+
### Kubernetes termination timing
114+
115+
Kubernetes defaults to a
116+
[`terminationGracePeriodSeconds`](https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#hook-handler-execution)
117+
of **30 seconds**. If you rely on graceful draining (HTTP servers, workers), leave headroom:
118+
119+
- Use a
120+
[`preStop`](https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks)
121+
hook (5-10 seconds) so load balancers stop routing
122+
- Set `WithTerminationTimeout(20 * time.Second)` to stay within the window
123+
124+
Tweak these values based on your environment and shutdown needs!
125+
126+
### Propagating shutdown into handlers
127+
128+
`http.Server.Shutdown` **does not cancel handler contexts immediately**. This is the correct and
129+
expected behavior for normal HTTP serving.
130+
131+
If you need handlers to observe process shutdown (rare, usually for long-running streaming
132+
endpoints), set:
133+
134+
```go
135+
graceful.Run(func(ctx context.Context) error {
136+
mux := http.NewServeMux()
137+
138+
mux.HandleFunc("/stream", func(w http.ResponseWriter, r *http.Request) {
139+
select {
140+
case <-time.After(30 * time.Second):
141+
fmt.Fprintln(w, "done")
142+
case <-r.Context().Done(): // will fire on shutdown
143+
http.Error(w, "shutting down", http.StatusServiceUnavailable)
144+
}
145+
})
146+
147+
server := &http.Server{
148+
Addr: ":8080",
149+
Handler: mux,
150+
BaseContext: func(_ net.Listener) context.Context {
151+
return ctx // propagate shutdown into handlers
152+
},
153+
}
154+
155+
return graceful.ListenAndServe(server, 10*time.Second)(ctx)
156+
})
157+
```
158+
159+
Handlers will then receive `r.Context().Done()` when shutdown begins.

0 commit comments

Comments
 (0)