Skip to content

Commit 7dad518

Browse files
committed
Implements ShutdownCode option and ShutdownSignal os.Signal wrapper
1 parent 1124297 commit 7dad518

4 files changed

Lines changed: 215 additions & 6 deletions

File tree

app.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -296,9 +296,12 @@ type App struct {
296296
errorHooks []ErrorHandler
297297
validate bool
298298
// Used to signal shutdowns.
299-
donesMu sync.Mutex // guards dones and shutdownSig
300-
dones []chan os.Signal
301-
shutdownSig os.Signal
299+
donesMu sync.Mutex // guards dones and shutdownSig
300+
dones []chan os.Signal
301+
shutdownSig os.Signal
302+
waitsMu sync.Mutex // guards waits and shutdownCode
303+
waits []chan ShutdownSignal
304+
shutdownSignal *ShutdownSignal
302305

303306
osExit func(code int) // os.Exit override; used for testing only
304307
}
@@ -737,6 +740,31 @@ func (app *App) Done() <-chan os.Signal {
737740
return c
738741
}
739742

743+
func (app *App) wait() <-chan ShutdownSignal {
744+
c := make(chan ShutdownSignal, 1)
745+
746+
app.waitsMu.Lock()
747+
defer app.waitsMu.Unlock()
748+
749+
if app.shutdownSignal != nil {
750+
c <- *app.shutdownSignal
751+
return c
752+
}
753+
754+
app.waits = append(app.waits, c)
755+
return c
756+
}
757+
758+
func (app *App) Wait(ctx context.Context) (ShutdownSignal, error) {
759+
c := app.wait()
760+
select {
761+
case s := <-c:
762+
return s, nil
763+
case <-ctx.Done():
764+
return ShutdownSignal{}, ctx.Err()
765+
}
766+
}
767+
740768
// StartTimeout returns the configured startup timeout. Apps default to using
741769
// DefaultTimeout, but users can configure this behavior using the
742770
// StartTimeout option.

shutdown.go

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ package fx
2323
import (
2424
"fmt"
2525
"os"
26+
27+
"go.uber.org/multierr"
2628
)
2729

2830
// Shutdowner provides a method that can manually trigger the shutdown of the
@@ -39,8 +41,26 @@ type ShutdownOption interface {
3941
apply(*shutdowner)
4042
}
4143

44+
type shutdownCode int
45+
46+
func (c shutdownCode) apply(s *shutdowner) {
47+
s.exitCode = int(c)
48+
}
49+
50+
// ShutdownCode implements a shutdown option that allows a user specify the
51+
// os.Exit code that an application should exit with.
52+
func ShutdownCode(code int) ShutdownOption {
53+
return shutdownCode(code)
54+
}
55+
4256
type shutdowner struct {
43-
app *App
57+
exitCode int
58+
app *App
59+
}
60+
61+
type ShutdownSignal struct {
62+
Signal os.Signal
63+
ExitCode int
4464
}
4565

4666
// Shutdown broadcasts a signal to all of the application's Done channels
@@ -49,14 +69,25 @@ type shutdowner struct {
4969
// In practice this means Shutdowner.Shutdown should not be called from an
5070
// fx.Invoke, but from a fx.Lifecycle.OnStart hook.
5171
func (s *shutdowner) Shutdown(opts ...ShutdownOption) error {
52-
return s.app.broadcastSignal(_sigTERM)
72+
for _, opt := range opts {
73+
opt.apply(s)
74+
}
75+
76+
return s.app.broadcastSignal(_sigTERM, s.exitCode)
5377
}
5478

5579
func (app *App) shutdowner() Shutdowner {
5680
return &shutdowner{app: app}
5781
}
5882

59-
func (app *App) broadcastSignal(signal os.Signal) error {
83+
func (app *App) broadcastSignal(signal os.Signal, code int) error {
84+
return multierr.Combine(
85+
app.broadcastDoneSignal(signal),
86+
app.broadcastWaitSignal(signal, code),
87+
)
88+
}
89+
90+
func (app *App) broadcastDoneSignal(signal os.Signal) error {
6091
app.donesMu.Lock()
6192
defer app.donesMu.Unlock()
6293

@@ -81,3 +112,32 @@ func (app *App) broadcastSignal(signal os.Signal) error {
81112

82113
return nil
83114
}
115+
116+
func (app *App) broadcastWaitSignal(signal os.Signal, code int) error {
117+
app.waitsMu.Lock()
118+
defer app.waitsMu.Unlock()
119+
120+
app.shutdownSignal = &ShutdownSignal{
121+
Signal: signal,
122+
ExitCode: code,
123+
}
124+
125+
var unsent int
126+
for _, wait := range app.waits {
127+
select {
128+
case wait <- *app.shutdownSignal:
129+
default:
130+
// shutdown called when wait channel has already received a
131+
// termination signal that has not been cleared
132+
unsent++
133+
}
134+
}
135+
136+
if unsent != 0 {
137+
return fmt.Errorf("failed to send %v codes to %v out of %v channels",
138+
signal, unsent, len(app.waits),
139+
)
140+
}
141+
142+
return nil
143+
}

shutdown_code_example_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2022 Uber Technologies, Inc.
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in
11+
// all copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
// THE SOFTWARE.
20+
21+
package fx_test
22+
23+
import (
24+
"context"
25+
"fmt"
26+
"time"
27+
28+
"go.uber.org/fx"
29+
)
30+
31+
func ExampleShutdownCode() {
32+
app := fx.New(
33+
fx.Invoke(func(shutdowner fx.Shutdowner) {
34+
// Call the shutdowner Shutdown method with a shutdown code
35+
// option
36+
shutdowner.Shutdown(fx.ShutdownCode(1))
37+
}),
38+
)
39+
40+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
41+
defer cancel()
42+
43+
app.Run()
44+
45+
signal, err := app.Wait(ctx)
46+
47+
if err != nil {
48+
panic(err)
49+
}
50+
51+
fmt.Printf("os.Exit(%v)\n", signal.ExitCode)
52+
53+
done := <-app.Done()
54+
55+
fmt.Printf(
56+
"Done signal %q equal to Shutdown signal %q: %v\n",
57+
signal.Signal,
58+
done,
59+
signal.Signal == done,
60+
)
61+
62+
// Output:
63+
// os.Exit(1)
64+
// Done signal "terminated" equal to Shutdown signal "terminated": true
65+
}

shutdown_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ package fx_test
2222

2323
import (
2424
"context"
25+
"fmt"
2526
"sync"
2627
"testing"
28+
"time"
2729

2830
"github.com/stretchr/testify/assert"
2931
"github.com/stretchr/testify/require"
@@ -87,6 +89,60 @@ func TestShutdown(t *testing.T) {
8789
assert.NotNil(t, <-done1, "done channel 1 did not receive signal")
8890
assert.NotNil(t, <-done2, "done channel 2 did not receive signal")
8991
})
92+
93+
t.Run("shutdown app with exit code(s)", func(t *testing.T) {
94+
t.Parallel()
95+
96+
t.Run("default", func(t *testing.T) {
97+
t.Parallel()
98+
app := fxtest.New(t,
99+
fx.Invoke(func(s fx.Shutdowner) {
100+
s.Shutdown()
101+
}),
102+
)
103+
done := app.Done()
104+
app.RequireStart().RequireStop()
105+
106+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
107+
defer cancel()
108+
109+
signal, err := app.Wait(ctx)
110+
assert.NoError(t, err, "error in app wait")
111+
assert.NotEmpty(t, signal, "no shutdown signal")
112+
assert.NotNil(t, signal.Signal)
113+
assert.Zero(t, signal.ExitCode)
114+
assert.Equal(t, signal.Signal, <-done)
115+
})
116+
117+
for expected := 0; expected <= 3; expected++ {
118+
expected := expected
119+
t.Run(fmt.Sprintf("with exit code %v", expected), func(t *testing.T) {
120+
t.Parallel()
121+
app := fxtest.New(
122+
t,
123+
fx.Invoke(func(s fx.Shutdowner) {
124+
assert.NoError(
125+
t,
126+
s.Shutdown(fx.ShutdownCode(expected)),
127+
"error in app shutdown",
128+
)
129+
}),
130+
)
131+
132+
done := app.Done()
133+
app.RequireStart().RequireStop()
134+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
135+
defer cancel()
136+
signal, err := app.Wait(ctx)
137+
138+
assert.NoError(t, err, "error in app wait")
139+
assert.NotEmpty(t, signal, "no shutdown signal")
140+
assert.NotNil(t, signal.Signal)
141+
assert.Equal(t, expected, signal.ExitCode)
142+
assert.Equal(t, signal.Signal, <-done)
143+
})
144+
}
145+
})
90146
}
91147

92148
func TestDataRace(t *testing.T) {

0 commit comments

Comments
 (0)