Skip to content

Commit da8b779

Browse files
knqyf263yutatokoi
authored andcommitted
feat: add graceful shutdown with signal handling (aquasecurity#9242)
Signed-off-by: knqyf263 <[email protected]>
1 parent cce86d3 commit da8b779

File tree

6 files changed

+167
-7
lines changed

6 files changed

+167
-7
lines changed

cmd/trivy/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ func run() error {
4141
return nil
4242
}
4343

44+
// Set up signal handling for graceful shutdown
45+
ctx, stop := commands.NotifyContext(context.Background())
46+
defer stop()
47+
4448
app := commands.NewApp()
45-
return app.Execute()
49+
return app.ExecuteContext(ctx)
4650
}

pkg/commands/signal.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package commands
2+
3+
import (
4+
"context"
5+
"os"
6+
"os/signal"
7+
"syscall"
8+
9+
"github.com/aquasecurity/trivy/pkg/log"
10+
)
11+
12+
// NotifyContext returns a context that is canceled when SIGINT or SIGTERM is received.
13+
// It also ensures cleanup of temporary files when the signal is received.
14+
//
15+
// When a signal is received, Trivy will attempt to gracefully shut down by canceling
16+
// the context and waiting for all operations to complete. If users want to force an
17+
// immediate exit, they can send a second SIGINT or SIGTERM signal.
18+
func NotifyContext(parent context.Context) (context.Context, context.CancelFunc) {
19+
ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
20+
21+
// Start a goroutine to handle cleanup when context is done
22+
go func() {
23+
<-ctx.Done()
24+
25+
// Log that we're shutting down gracefully
26+
log.Info("Received signal, attempting graceful shutdown...")
27+
log.Info("Press Ctrl+C again to force exit")
28+
29+
// TODO: Add any necessary cleanup logic here
30+
31+
// Clean up signal handling
32+
// After calling stop(), a second signal will cause immediate termination
33+
stop()
34+
}()
35+
36+
return ctx, stop
37+
}

pkg/oci/artifact.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/aquasecurity/trivy/pkg/log"
2222
"github.com/aquasecurity/trivy/pkg/remote"
2323
"github.com/aquasecurity/trivy/pkg/version/doc"
24+
xio "github.com/aquasecurity/trivy/pkg/x/io"
2425
)
2526

2627
const (
@@ -188,7 +189,7 @@ func (a *Artifact) download(ctx context.Context, layer v1.Layer, fileName, dir s
188189
}()
189190

190191
// Download the layer content into a temporal file
191-
if _, err = io.Copy(f, pr); err != nil {
192+
if _, err = xio.Copy(ctx, f, pr); err != nil {
192193
return xerrors.Errorf("copy error: %w", err)
193194
}
194195

pkg/rpc/server/listen.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package server
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"net/http"
78
"os"
89
"strings"
@@ -62,20 +63,46 @@ func (s Server) ListenAndServe(ctx context.Context, serverCache cache.Cache, ski
6263
requestWg := &sync.WaitGroup{}
6364
dbUpdateWg := &sync.WaitGroup{}
6465

66+
server := &http.Server{
67+
Addr: s.addr,
68+
Handler: s.NewServeMux(ctx, serverCache, dbUpdateWg, requestWg),
69+
ReadHeaderTimeout: 10 * time.Second,
70+
}
71+
72+
// Start DB update worker
6573
go func() {
6674
worker := newDBWorker(db.NewClient(s.dbDir, true, db.WithDBRepository(s.dbRepositories)))
75+
ticker := time.NewTicker(updateInterval)
76+
defer ticker.Stop()
77+
6778
for {
68-
time.Sleep(updateInterval)
69-
if err := worker.update(ctx, s.appVersion, s.dbDir, skipDBUpdate, dbUpdateWg, requestWg, s.RegistryOptions); err != nil {
70-
log.Errorf("%+v\n", err)
79+
select {
80+
case <-ctx.Done():
81+
log.Debug("Server shutting down gracefully...")
82+
83+
// Give active requests time to complete
84+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
85+
if err := server.Shutdown(shutdownCtx); err != nil {
86+
log.Errorf("Server shutdown error: %v", err)
87+
}
88+
cancel()
89+
return
90+
case <-ticker.C:
91+
if err := worker.update(ctx, s.appVersion, s.dbDir, skipDBUpdate, dbUpdateWg, requestWg, s.RegistryOptions); err != nil {
92+
log.Errorf("%+v\n", err)
93+
}
7194
}
7295
}
7396
}()
7497

75-
mux := s.NewServeMux(ctx, serverCache, dbUpdateWg, requestWg)
7698
log.Infof("Listening %s...", s.addr)
7799

78-
return http.ListenAndServe(s.addr, mux)
100+
// This will block until Shutdown is called
101+
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
102+
return xerrors.Errorf("server error: %w", err)
103+
}
104+
105+
return nil
79106
}
80107

81108
func (s Server) NewServeMux(ctx context.Context, serverCache cache.Cache, dbUpdateWg, requestWg *sync.WaitGroup) *http.ServeMux {

pkg/x/io/io.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io
22

33
import (
44
"bytes"
5+
"context"
56
"io"
67

78
"golang.org/x/xerrors"
@@ -71,3 +72,27 @@ type nopCloser struct {
7172
}
7273

7374
func (nopCloser) Close() error { return nil }
75+
76+
// readerFunc is a function that implements io.Reader
77+
type readerFunc func([]byte) (int, error)
78+
79+
func (f readerFunc) Read(p []byte) (int, error) {
80+
return f(p)
81+
}
82+
83+
// Copy copies from src to dst until either EOF is reached on src or the context is canceled.
84+
// It returns the number of bytes copied and the first error encountered while copying, if any.
85+
//
86+
// Note: This implementation wraps the reader with a context check, which means it won't
87+
// benefit from WriterTo optimization in io.Copy if the source implements it. This is a trade-off
88+
// for being able to cancel the operation on context cancellation.
89+
func Copy(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
90+
return io.Copy(dst, readerFunc(func(p []byte) (int, error) {
91+
select {
92+
case <-ctx.Done():
93+
return 0, ctx.Err()
94+
default:
95+
return src.Read(p)
96+
}
97+
}))
98+
}

pkg/x/io/io_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestCopy(t *testing.T) {
14+
t.Run("successful copy", func(t *testing.T) {
15+
ctx := t.Context()
16+
src := strings.NewReader("hello world")
17+
dst := &bytes.Buffer{}
18+
19+
n, err := Copy(ctx, dst, src)
20+
require.NoError(t, err)
21+
assert.Equal(t, int64(11), n)
22+
assert.Equal(t, "hello world", dst.String())
23+
})
24+
25+
t.Run("context canceled before read", func(t *testing.T) {
26+
ctx, cancel := context.WithCancel(t.Context())
27+
cancel() // Cancel immediately
28+
29+
src := strings.NewReader("hello world")
30+
dst := &bytes.Buffer{}
31+
32+
n, err := Copy(ctx, dst, src)
33+
require.ErrorIs(t, err, context.Canceled)
34+
assert.Equal(t, int64(0), n)
35+
assert.Empty(t, dst.String())
36+
})
37+
38+
t.Run("context canceled during read", func(t *testing.T) {
39+
ctx, cancel := context.WithCancel(t.Context())
40+
41+
// Create a reader that will be canceled after first read
42+
reader := &dummyReader{
43+
cancel: cancel, // Cancel after first read
44+
}
45+
dst := &bytes.Buffer{}
46+
47+
n, err := Copy(ctx, dst, reader)
48+
require.ErrorIs(t, err, context.Canceled)
49+
// Should have written first chunk before cancellation
50+
assert.Equal(t, int64(5), n)
51+
assert.Equal(t, "dummy", dst.String())
52+
})
53+
}
54+
55+
// dummyReader returns the same data on every Read call
56+
type dummyReader struct {
57+
cancel context.CancelFunc
58+
}
59+
60+
func (r *dummyReader) Read(p []byte) (int, error) {
61+
n := copy(p, "dummy")
62+
if r.cancel != nil {
63+
r.cancel() // Simulate cancellation after first read
64+
}
65+
return n, nil
66+
}

0 commit comments

Comments
 (0)