├── .github └── workflows │ ├── golang-ci-lint.yaml │ └── pipeline.yaml ├── .golangci.yaml ├── LICENSE ├── Makefile ├── README.md ├── acceptancetests ├── blackboxtestthings.go ├── slow_handler.go ├── withgracefulshutdown │ ├── main.go │ └── main_test.go └── withoutgracefulshutdown │ ├── main.go │ └── main_test.go ├── assert └── assert.go ├── go.mod ├── server.go ├── server_spy_test.go ├── server_test.go └── signal.go /.github/workflows/golang-ci-lint.yaml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | - main 9 | pull_request: 10 | permissions: 11 | contents: read 12 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 13 | # pull-requests: read 14 | jobs: 15 | golangci: 16 | name: lint 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/setup-go@v3 20 | with: 21 | go-version: 1.18 22 | - uses: actions/checkout@v3 23 | - name: golangci-lint 24 | uses: golangci/golangci-lint-action@v3 25 | with: 26 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 27 | version: v1.46.2 28 | 29 | # Optional: working directory, useful for monorepos 30 | # working-directory: somedir 31 | 32 | # Optional: golangci-lint command line arguments. 33 | # args: --issues-exit-code=0 34 | 35 | # Optional: show only new issues if it's a pull request. The default value is `false`. 36 | # only-new-issues: true 37 | 38 | # Optional: if set to true then the all caching functionality will be complete disabled, 39 | # takes precedence over all other caching options. 40 | # skip-cache: true 41 | 42 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 43 | # skip-pkg-cache: true 44 | 45 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 46 | # skip-build-cache: true -------------------------------------------------------------------------------- /.github/workflows/pipeline.yaml: -------------------------------------------------------------------------------- 1 | name: Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Install Go 13 | uses: actions/setup-go@v2 14 | with: 15 | go-version: 1.18.x 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | - name: Test 19 | run: go test ./... -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | run: 2 | go: 1.18 3 | 4 | linters: 5 | # Enable extra linters besides the default ones 6 | enable: 7 | - goimports 8 | - godot 9 | - goconst 10 | - govet 11 | - gofmt 12 | - unconvert 13 | - misspell 14 | - whitespace 15 | - revive -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2018 Mat Ryer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | golangci-lint run 3 | go test -count=1 ./... 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graceful shutdown decorator 2 | [![Go Reference](https://pkg.go.dev/badge/github.com/quii/go-graceful-shutdown.svg)](https://pkg.go.dev/github.com/quii/go-graceful-shutdown) 3 | ![Pipeline](https://github.com/quii/go-graceful-shutdown/actions/workflows/pipeline.yaml/badge.svg) 4 | ![Lint](https://github.com/quii/go-graceful-shutdown/actions/workflows/golang-ci-lint.yaml/badge.svg) 5 | 6 | A wrapper for your Go HTTP server so that it will finish responding to in-flight requests on interrupt signals before shutting down. 7 | 8 | ```go 9 | func main() { 10 | var ( 11 | ctx = context.Background() 12 | httpServer = &http.Server{Addr: ":8080", Handler: http.HandlerFunc(acceptancetests.SlowHandler)} 13 | server = gracefulshutdown.NewServer(httpServer) 14 | ) 15 | 16 | if err := server.ListenAndServe(ctx); err != nil { 17 | // this will typically happen if our responses aren't written before the ctx deadline, not much can be done 18 | log.Fatalf("uh oh, didnt shutdown gracefully, some responses may have been lost %v", err) 19 | } 20 | 21 | // hopefully, you'll always see this instead 22 | log.Println("shutdown gracefully! all responses were sent") 23 | } 24 | ``` 25 | 26 | ## The problem 27 | 28 | - You're running a HTTP server, and deploying it many times per day 29 | - Sometimes, you might be deploying a new version of the code while it is trying to handle a request, and if you're not handling this gracefully you'll either: 30 | - Not get a response 31 | - Or the reverse-proxy in front of your service will complain about your service and return a 502 32 | 33 | ## The solution 34 | 35 | Graceful shutdown! 36 | 37 | - Listen to interrupt signals 38 | - Rather than killing the program straight away, instead call [http.Server.Shutdown](https://pkg.go.dev/net/http#Server.Shutdown) which will let requests, connections e.t.c drain _before_ killing the server 39 | - This should mean in most cases, the server will finish the currently running requests before stopping 40 | 41 | There are a few examples of this out there, I thought I'd roll my own, so I could understand it better, and structure it in a non-confusing way, hopefully. 42 | 43 | Almost everything boils down to a decorator pattern in the end. You provide my library a `*http.Server` and it'll return you back a `*gracefulshutdown.Server`. Just call `ListenAndServe`, and it'll gracefully shutdown on [an os signal](https://github.com/quii/go-graceful-shutdown/blob/main/signal.go#L11). 44 | 45 | ## Example usage and testing 46 | 47 | See [acceptancetests/withgracefulshutdown/main.go](https://github.com/quii/go-graceful-shutdown/blob/main/acceptancetests/withgracefulshutdown/main.go) for an example 48 | 49 | There are two binaries in this project with accompanying acceptance tests to verify the functionality that live inside `/acceptancetests`. 50 | 51 | Both tests build the binaries, run them, fire a `HTTP GET` and then send an interrupt signal to tell the server to stop. 52 | 53 | The two binaries allow us to test both scenarios 54 | 55 | 1. A "slow" HTTP server with no graceful shutdown. For this we assert that we do get an error, because the server should shutdown immediately and any in-flight requests will fail. 56 | 2. Another slow HTTP server _with_ graceful shutdown. Same test again, but this time we assert we don't get an error as we expect to get a response before the server is terminated. -------------------------------------------------------------------------------- /acceptancetests/blackboxtestthings.go: -------------------------------------------------------------------------------- 1 | package acceptancetests 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | const ( 15 | baseBinName = "temp-testbinary" 16 | ) 17 | 18 | func LaunchTestProgram(port string) (cleanup func(), sendInterrupt func() error, err error) { 19 | binName, err := buildBinary() 20 | if err != nil { 21 | return nil, nil, err 22 | } 23 | 24 | sendInterrupt, kill, err := runServer(binName, port) 25 | 26 | cleanup = func() { 27 | if kill != nil { 28 | kill() 29 | } 30 | os.Remove(binName) 31 | } 32 | 33 | if err != nil { 34 | cleanup() // even though it's not listening correctly, the program could still be running 35 | return nil, nil, err 36 | } 37 | 38 | return cleanup, sendInterrupt, nil 39 | } 40 | 41 | func buildBinary() (string, error) { 42 | binName := randomString(10) + "-" + baseBinName 43 | 44 | build := exec.Command("go", "build", "-o", binName) 45 | 46 | if err := build.Run(); err != nil { 47 | return "", fmt.Errorf("cannot build tool %s: %s", binName, err) 48 | } 49 | return binName, nil 50 | } 51 | 52 | func runServer(binName string, port string) (sendInterrupt func() error, kill func(), err error) { 53 | dir, err := os.Getwd() 54 | if err != nil { 55 | return nil, nil, err 56 | } 57 | 58 | cmdPath := filepath.Join(dir, binName) 59 | 60 | cmd := exec.Command(cmdPath) 61 | 62 | if err := cmd.Start(); err != nil { 63 | return nil, nil, fmt.Errorf("cannot run temp converter: %s", err) 64 | } 65 | 66 | kill = func() { 67 | _ = cmd.Process.Kill() 68 | } 69 | 70 | sendInterrupt = func() error { 71 | return cmd.Process.Signal(syscall.SIGTERM) 72 | } 73 | 74 | err = waitForServerListening(port) 75 | 76 | return 77 | } 78 | 79 | func waitForServerListening(port string) error { 80 | for i := 0; i < 30; i++ { 81 | conn, _ := net.Dial("tcp", net.JoinHostPort("localhost", port)) 82 | if conn != nil { 83 | conn.Close() 84 | return nil 85 | } 86 | time.Sleep(100 * time.Millisecond) 87 | } 88 | return fmt.Errorf("nothing seems to be listening on localhost:%s", port) 89 | } 90 | 91 | func randomString(n int) string { 92 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 93 | 94 | s := make([]rune, n) 95 | for i := range s { 96 | s[i] = letters[rand.Intn(len(letters))] 97 | } 98 | return string(s) 99 | } 100 | -------------------------------------------------------------------------------- /acceptancetests/slow_handler.go: -------------------------------------------------------------------------------- 1 | package acceptancetests 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | func SlowHandler(w http.ResponseWriter, r *http.Request) { 10 | time.Sleep(2 * time.Second) 11 | fmt.Fprint(w, "Hello, world") 12 | } 13 | -------------------------------------------------------------------------------- /acceptancetests/withgracefulshutdown/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net/http" 7 | 8 | gracefulshutdown "github.com/quii/go-graceful-shutdown" 9 | "github.com/quii/go-graceful-shutdown/acceptancetests" 10 | ) 11 | 12 | func main() { 13 | var ( 14 | ctx = context.Background() 15 | httpServer = &http.Server{Addr: ":8080", Handler: http.HandlerFunc(acceptancetests.SlowHandler)} 16 | server = gracefulshutdown.NewServer(httpServer) 17 | ) 18 | 19 | if err := server.ListenAndServe(ctx); err != nil { 20 | // this will typically happen if our responses aren't written before the ctx deadline, not much can be done 21 | log.Fatalf("uh oh, didnt shutdown gracefully, some responses may have been lost %v", err) 22 | } 23 | 24 | // hopefully, you'll always see this instead 25 | log.Println("shutdown gracefully! all responses were sent") 26 | } 27 | -------------------------------------------------------------------------------- /acceptancetests/withgracefulshutdown/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/quii/go-graceful-shutdown/acceptancetests" 8 | "github.com/quii/go-graceful-shutdown/assert" 9 | ) 10 | 11 | const ( 12 | port = "8080" 13 | url = "http://localhost:" + port 14 | ) 15 | 16 | func TestGracefulShutdown(t *testing.T) { 17 | cleanup, sendInterrupt, err := acceptancetests.LaunchTestProgram(port) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | t.Cleanup(cleanup) 22 | 23 | // just check the server works before we shut things down 24 | assert.CanGet(t, url) 25 | 26 | // fire off a request, and before it has a chance to respond send SIGTERM. 27 | time.AfterFunc(50*time.Millisecond, func() { 28 | assert.NoError(t, sendInterrupt()) 29 | }) 30 | // Without graceful shutdown, this would fail 31 | assert.CanGet(t, url) 32 | 33 | // after interrupt, the server should be shutdown, and no more requests will work 34 | assert.CantGet(t, url) 35 | } 36 | -------------------------------------------------------------------------------- /acceptancetests/withoutgracefulshutdown/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/quii/go-graceful-shutdown/acceptancetests" 8 | ) 9 | 10 | func main() { 11 | server := &http.Server{Addr: ":8081", Handler: http.HandlerFunc(acceptancetests.SlowHandler)} 12 | 13 | if err := server.ListenAndServe(); err != nil { 14 | log.Fatal(err) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /acceptancetests/withoutgracefulshutdown/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/quii/go-graceful-shutdown/acceptancetests" 8 | "github.com/quii/go-graceful-shutdown/assert" 9 | ) 10 | 11 | const ( 12 | port = "8081" 13 | url = "http://localhost:" + port 14 | ) 15 | 16 | func TestNonGracefulShutdown(t *testing.T) { 17 | cleanup, sendInterrupt, err := acceptancetests.LaunchTestProgram(port) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | t.Cleanup(cleanup) 22 | 23 | // just check the server works before we shut things down 24 | assert.CanGet(t, url) 25 | 26 | // fire off a request, it should fail because the server will be interrupted 27 | time.AfterFunc(50*time.Millisecond, func() { 28 | assert.NoError(t, sendInterrupt()) 29 | }) 30 | assert.CantGet(t, url) 31 | 32 | // after interrupt, the server should be shutdown, and no more requests will work 33 | assert.CantGet(t, url) 34 | } 35 | -------------------------------------------------------------------------------- /assert/assert.go: -------------------------------------------------------------------------------- 1 | package assert 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func Equal[T comparable](t testing.TB, got, want T) { 10 | t.Helper() 11 | if got != want { 12 | t.Errorf("got %v, want %v", got, want) 13 | } 14 | } 15 | 16 | func NoError(t testing.TB, err error) { 17 | if err == nil { 18 | return 19 | } 20 | t.Helper() 21 | t.Fatalf("didnt expect an err, but got one %v", err) 22 | } 23 | 24 | func Error(t testing.TB, err error) { 25 | t.Helper() 26 | if err == nil { 27 | t.Error("expected an error but didnt get one") 28 | } 29 | } 30 | 31 | func SignalSent[T any](t testing.TB, signal <-chan T, signalName string) { 32 | t.Helper() 33 | select { 34 | case <-signal: 35 | case <-time.After(500 * time.Millisecond): 36 | t.Errorf("timed out waiting %q to happen", signalName) 37 | } 38 | } 39 | 40 | func CanGet(t testing.TB, url string) { 41 | errChan := make(chan error) 42 | 43 | go func() { 44 | res, err := http.Get(url) 45 | if err != nil { 46 | errChan <- err 47 | return 48 | } 49 | res.Body.Close() 50 | errChan <- nil 51 | }() 52 | 53 | select { 54 | case err := <-errChan: 55 | NoError(t, err) 56 | case <-time.After(3 * time.Second): 57 | t.Errorf("timed out waiting for request to %q", url) 58 | } 59 | } 60 | 61 | func CantGet(t testing.TB, url string) { 62 | t.Helper() 63 | errChan := make(chan error, 1) 64 | 65 | go func() { 66 | res, err := http.Get(url) 67 | if err != nil { 68 | errChan <- err 69 | return 70 | } 71 | res.Body.Close() 72 | errChan <- nil 73 | }() 74 | 75 | select { 76 | case err := <-errChan: 77 | Error(t, err) 78 | case <-time.After(500 * time.Millisecond): 79 | t.Errorf("timed out waiting for request to %q", url) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/quii/go-graceful-shutdown 2 | 3 | go 1.18 4 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package gracefulshutdown 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "time" 8 | ) 9 | 10 | const ( 11 | k8sDefaultTerminationGracePeriod = 30 * time.Second 12 | ) 13 | 14 | type ( 15 | // HTTPServer is an abstraction of something that listens for connections and do HTTP things. 99% of the time, you'll pass in a net/http/Server. 16 | HTTPServer interface { 17 | ListenAndServe() error 18 | Shutdown(ctx context.Context) error 19 | } 20 | 21 | // Server wraps around a HTTPServer and will gracefully shutdown when it receives a shutdown signal. 22 | Server struct { 23 | shutdown <-chan os.Signal 24 | delegate HTTPServer 25 | timeout time.Duration 26 | } 27 | 28 | // ServerOption provides ways of configuring Server. 29 | ServerOption func(server *Server) 30 | ) 31 | 32 | // WithShutdownSignal WithShutdownSignals allows you to listen to whatever signals you like, rather than the default ones defined in signal.go. 33 | func WithShutdownSignal(shutdownSignal <-chan os.Signal) ServerOption { 34 | return func(server *Server) { 35 | server.shutdown = shutdownSignal 36 | } 37 | } 38 | 39 | // WithTimeout lets you set your own timeout for waiting for graceful shutdown. By default this is set to 30 seconds (k8s' default TerminationGracePeriod). 40 | func WithTimeout(timeout time.Duration) ServerOption { 41 | return func(server *Server) { 42 | server.timeout = timeout 43 | } 44 | } 45 | 46 | // NewServer returns a Server that can gracefully shutdown on shutdown signals. 47 | func NewServer(server HTTPServer, options ...ServerOption) *Server { 48 | s := &Server{ 49 | delegate: server, 50 | timeout: k8sDefaultTerminationGracePeriod, 51 | shutdown: newInterruptSignalChannel(), 52 | } 53 | 54 | for _, option := range options { 55 | option(s) 56 | } 57 | 58 | return s 59 | } 60 | 61 | // ListenAndServe will call the ListenAndServe function of the delegate HTTPServer you passed in at construction. On a signal being sent to the shutdown signal provided in the constructor, it will call the server's Shutdown method to attempt to gracefully shutdown. 62 | func (s *Server) ListenAndServe(ctx context.Context) error { 63 | select { 64 | case err := <-s.delegateListenAndServe(): 65 | return err 66 | case <-ctx.Done(): 67 | return s.shutdownDelegate(ctx) 68 | case <-s.shutdown: 69 | return s.shutdownDelegate(ctx) 70 | } 71 | } 72 | 73 | func (s *Server) delegateListenAndServe() chan error { 74 | listenErr := make(chan error) 75 | 76 | go func() { 77 | if err := s.delegate.ListenAndServe(); err != nil && err != http.ErrServerClosed { 78 | listenErr <- err 79 | } 80 | }() 81 | 82 | return listenErr 83 | } 84 | 85 | func (s *Server) shutdownDelegate(ctx context.Context) error { 86 | ctx, cancel := context.WithTimeout(ctx, s.timeout) 87 | defer cancel() 88 | 89 | if err := s.delegate.Shutdown(ctx); err != nil && err != http.ErrServerClosed { 90 | return err 91 | } 92 | return ctx.Err() 93 | } 94 | -------------------------------------------------------------------------------- /server_spy_test.go: -------------------------------------------------------------------------------- 1 | package gracefulshutdown_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/quii/go-graceful-shutdown/assert" 8 | ) 9 | 10 | type SpyServer struct { 11 | ListenAndServeFunc func() error 12 | listened chan struct{} 13 | 14 | ShutdownFunc func() error 15 | shutdown chan struct{} 16 | } 17 | 18 | func NewSpyServer() *SpyServer { 19 | return &SpyServer{ 20 | listened: make(chan struct{}, 1), 21 | shutdown: make(chan struct{}, 1), 22 | } 23 | } 24 | 25 | func (s *SpyServer) ListenAndServe() error { 26 | s.listened <- struct{}{} 27 | return s.ListenAndServeFunc() 28 | } 29 | 30 | func (s *SpyServer) AssertListened(t *testing.T) { 31 | t.Helper() 32 | assert.SignalSent(t, s.listened, "listen") 33 | } 34 | 35 | func (s *SpyServer) Shutdown(ctx context.Context) error { 36 | s.shutdown <- struct{}{} 37 | return s.ShutdownFunc() 38 | } 39 | 40 | func (s *SpyServer) AssertShutdown(t *testing.T) { 41 | t.Helper() 42 | assert.SignalSent(t, s.shutdown, "shutdown") 43 | } 44 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package gracefulshutdown_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "testing" 8 | "time" 9 | 10 | gracefulshutdown "github.com/quii/go-graceful-shutdown" 11 | "github.com/quii/go-graceful-shutdown/assert" 12 | ) 13 | 14 | func TestGracefulShutdownServer_Listen(t *testing.T) { 15 | t.Run("happy path, listen, wait for interrupt, shutdown gracefully", func(t *testing.T) { 16 | var ( 17 | interrupt = make(chan os.Signal) 18 | spyServer = NewSpyServer() 19 | server = gracefulshutdown.NewServer(spyServer, gracefulshutdown.WithShutdownSignal(interrupt)) 20 | ctx = context.Background() 21 | ) 22 | 23 | spyServer.ListenAndServeFunc = func() error { 24 | return nil 25 | } 26 | spyServer.ShutdownFunc = func() error { 27 | return nil 28 | } 29 | 30 | go func() { 31 | if err := server.ListenAndServe(ctx); err != nil { 32 | t.Error(err) 33 | } 34 | }() 35 | 36 | // verify we call listen on the delegate server 37 | spyServer.AssertListened(t) 38 | 39 | // verify we call shutdown on the delegate server when an interrupt is made 40 | interrupt <- os.Interrupt 41 | spyServer.AssertShutdown(t) 42 | }) 43 | 44 | t.Run("when listen fails, return error", func(t *testing.T) { 45 | var ( 46 | interrupt = make(chan os.Signal) 47 | spyServer = NewSpyServer() 48 | server = gracefulshutdown.NewServer(spyServer, gracefulshutdown.WithShutdownSignal(interrupt)) 49 | err = errors.New("oh no") 50 | ctx = context.Background() 51 | ) 52 | 53 | spyServer.ListenAndServeFunc = func() error { 54 | return err 55 | } 56 | 57 | gotErr := server.ListenAndServe(ctx) 58 | 59 | assert.Equal(t, gotErr.Error(), err.Error()) 60 | }) 61 | 62 | t.Run("shutdown error gets propagated", func(t *testing.T) { 63 | var ( 64 | interrupt = make(chan os.Signal) 65 | errChan = make(chan error) 66 | spyServer = NewSpyServer() 67 | server = gracefulshutdown.NewServer(spyServer, gracefulshutdown.WithShutdownSignal(interrupt)) 68 | err = errors.New("oh no") 69 | ctx = context.Background() 70 | ) 71 | 72 | spyServer.ListenAndServeFunc = func() error { 73 | return nil 74 | } 75 | spyServer.ShutdownFunc = func() error { 76 | return err 77 | } 78 | 79 | go func() { 80 | errChan <- server.ListenAndServe(ctx) 81 | }() 82 | 83 | interrupt <- os.Interrupt 84 | 85 | select { 86 | case gotErr := <-errChan: 87 | assert.Equal(t, gotErr.Error(), err.Error()) 88 | case <-time.After(500 * time.Millisecond): 89 | t.Error("timed out waiting for shutdown error to be propagated") 90 | } 91 | }) 92 | 93 | t.Run("context passed in can trigger shutdown too", func(t *testing.T) { 94 | var ( 95 | interrupt = make(chan os.Signal) 96 | spyServer = NewSpyServer() 97 | server = gracefulshutdown.NewServer(spyServer, gracefulshutdown.WithShutdownSignal(interrupt)) 98 | ctx, cancel = context.WithCancel(context.Background()) 99 | ) 100 | 101 | spyServer.ListenAndServeFunc = func() error { 102 | return nil 103 | } 104 | spyServer.ShutdownFunc = func() error { 105 | return nil 106 | } 107 | 108 | go func() { 109 | if err := server.ListenAndServe(ctx); err != nil && err != context.Canceled { 110 | t.Error(err) 111 | } 112 | }() 113 | 114 | // verify we call listen on the delegate server 115 | spyServer.AssertListened(t) 116 | 117 | // verify we call shutdown on the delegate server when an interrupt is made 118 | cancel() 119 | spyServer.AssertShutdown(t) 120 | }) 121 | } 122 | -------------------------------------------------------------------------------- /signal.go: -------------------------------------------------------------------------------- 1 | package gracefulshutdown 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | ) 8 | 9 | var ( 10 | signalsToListenTo = []os.Signal{ 11 | syscall.SIGINT, syscall.SIGHUP, syscall.SIGQUIT, syscall.SIGTERM, 12 | } 13 | ) 14 | 15 | func newInterruptSignalChannel() <-chan os.Signal { 16 | osSignal := make(chan os.Signal, 1) 17 | signal.Notify(osSignal, signalsToListenTo...) 18 | return osSignal 19 | } 20 | --------------------------------------------------------------------------------