Skip to content

Commit ae3b3de

Browse files
authored
Merge pull request #4531 from ATKasem/fix/postshutdown-hook-error-and-double-fire
🐛 fix: OnPostShutdown hook receives the real error and fires once
2 parents 6a52c1d + d249e40 commit ae3b3de

3 files changed

Lines changed: 100 additions & 12 deletions

File tree

app.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1325,7 +1325,9 @@ func (app *App) ShutdownWithContext(ctx context.Context) error {
13251325

13261326
// Execute the Shutdown hook
13271327
app.hooks.executeOnPreShutdownHooks()
1328-
defer app.hooks.executeOnPostShutdownHooks(err)
1328+
// Use a closure so the hooks receive the final error; a plain
1329+
// `defer ...(err)` would capture the nil value at registration time.
1330+
defer func() { app.hooks.executeOnPostShutdownHooks(err) }()
13291331

13301332
err = app.server.ShutdownWithContext(ctx)
13311333
return err

listen.go

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -606,18 +606,13 @@ func (app *App) printRoutesMessage() {
606606
func (app *App) gracefulShutdown(ctx context.Context, cfg *ListenConfig) {
607607
<-ctx.Done()
608608

609-
var err error
610-
609+
// The OnPostShutdown hooks are fired by ShutdownWithContext (via
610+
// Shutdown/ShutdownWithTimeout) with the real error, so we must not fire
611+
// them again here or they would run twice. That error is already delivered
612+
// to those hooks, so it is intentionally ignored here.
611613
if cfg != nil && cfg.ShutdownTimeout != 0 {
612-
err = app.ShutdownWithTimeout(cfg.ShutdownTimeout) //nolint:contextcheck // TODO: Implement it
614+
_ = app.ShutdownWithTimeout(cfg.ShutdownTimeout) //nolint:errcheck,contextcheck // error is delivered to OnPostShutdown hooks
613615
} else {
614-
err = app.Shutdown() //nolint:contextcheck // TODO: Implement it
616+
_ = app.Shutdown() //nolint:errcheck,contextcheck // error is delivered to OnPostShutdown hooks
615617
}
616-
617-
if err != nil {
618-
app.hooks.executeOnPostShutdownHooks(err)
619-
return
620-
}
621-
622-
app.hooks.executeOnPostShutdownHooks(nil)
623618
}

listen_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,97 @@ func Test_Listen_Graceful_Shutdown(t *testing.T) {
5353
})
5454
}
5555

56+
// go test -run Test_ShutdownWithContext_PostShutdownHookReceivesError
57+
func Test_ShutdownWithContext_PostShutdownHookReceivesError(t *testing.T) {
58+
app := New()
59+
app.Get("/", func(c Ctx) error {
60+
time.Sleep(10 * time.Millisecond)
61+
return c.SendString("ok")
62+
})
63+
64+
hookErr := make(chan error, 1)
65+
app.Hooks().OnPostShutdown(func(err error) error {
66+
hookErr <- err
67+
return nil
68+
})
69+
70+
ln := fasthttputil.NewInmemoryListener()
71+
go func() {
72+
_ = app.Listener(ln, ListenConfig{DisableStartupMessage: true}) //nolint:errcheck // not needed
73+
}()
74+
75+
require.Eventually(t, func() bool {
76+
conn, err := ln.Dial()
77+
if err == nil {
78+
_ = conn.Close() //nolint:errcheck // not needed
79+
return true
80+
}
81+
return false
82+
}, time.Second, 20*time.Millisecond, "server failed to become ready")
83+
84+
// Keep a request in flight so shutdown cannot drain before the 1ns deadline.
85+
conn, err := ln.Dial()
86+
require.NoError(t, err)
87+
t.Cleanup(func() { _ = conn.Close() }) //nolint:errcheck // not needed
88+
_, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: example.com\r\n"))
89+
require.NoError(t, err)
90+
91+
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
92+
defer cancel()
93+
shutdownErr := app.ShutdownWithContext(ctx)
94+
require.Error(t, shutdownErr)
95+
96+
select {
97+
case got := <-hookErr:
98+
// The hook must receive the actual shutdown error, not the nil value
99+
// captured when the defer was registered.
100+
require.Equal(t, shutdownErr, got)
101+
case <-time.After(time.Second):
102+
t.Fatal("OnPostShutdown hook was not called")
103+
}
104+
}
105+
106+
// go test -run Test_GracefulShutdown_PostShutdownFiresOnce
107+
func Test_GracefulShutdown_PostShutdownFiresOnce(t *testing.T) {
108+
app := New()
109+
app.Get("/", func(c Ctx) error { return c.SendString("ok") })
110+
111+
fires := make(chan error, 8)
112+
app.Hooks().OnPostShutdown(func(err error) error {
113+
fires <- err
114+
return nil
115+
})
116+
117+
ln := fasthttputil.NewInmemoryListener()
118+
gctx, gcancel := context.WithCancel(context.Background())
119+
go func() {
120+
_ = app.Listener(ln, ListenConfig{DisableStartupMessage: true, GracefulContext: gctx}) //nolint:errcheck,contextcheck // not needed
121+
}()
122+
123+
require.Eventually(t, func() bool {
124+
conn, err := ln.Dial()
125+
if err == nil {
126+
_ = conn.Close() //nolint:errcheck // not needed
127+
return true
128+
}
129+
return false
130+
}, time.Second, 20*time.Millisecond, "server failed to become ready")
131+
132+
gcancel() // trigger graceful shutdown
133+
134+
// The hook must fire exactly once.
135+
select {
136+
case <-fires:
137+
case <-time.After(2 * time.Second):
138+
t.Fatal("OnPostShutdown was not called")
139+
}
140+
select {
141+
case <-fires:
142+
t.Fatal("OnPostShutdown fired more than once")
143+
case <-time.After(300 * time.Millisecond):
144+
}
145+
}
146+
56147
func testGracefulShutdown(t *testing.T, shutdownTimeout time.Duration) {
57148
t.Helper()
58149

0 commit comments

Comments
 (0)