Skip to content

Commit 7d92573

Browse files
authored
Merge pull request #4599 from laurazard/plugin-signal-handling
cli-plugins: terminate plugin when CLI exits
2 parents ce6832a + 1554ac3 commit 7d92573

7 files changed

Lines changed: 123 additions & 67 deletions

File tree

cli-plugins/plugin/plugin.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package plugin
22

33
import (
4+
"context"
45
"encoding/json"
6+
"errors"
57
"fmt"
8+
"io"
9+
"net"
610
"os"
711
"sync"
812

@@ -14,6 +18,11 @@ import (
1418
"github.com/spf13/cobra"
1519
)
1620

21+
// CLIPluginSocketEnvKey is used to pass the plugin being
22+
// executed the abstract socket name it should listen on to know
23+
// when the CLI has exited.
24+
const CLIPluginSocketEnvKey = "DOCKER_CLI_PLUGIN_SOCKET"
25+
1726
// PersistentPreRunE must be called by any plugin command (or
1827
// subcommand) which uses the cobra `PersistentPreRun*` hook. Plugins
1928
// which do not make use of `PersistentPreRun*` do not need to call
@@ -24,14 +33,56 @@ import (
2433
// called.
2534
var PersistentPreRunE func(*cobra.Command, []string) error
2635

36+
// closeOnCLISocketClose connects to the socket specified
37+
// by the DOCKER_CLI_PLUGIN_SOCKET env var, if present, and attempts
38+
// to read from it until it receives an EOF, which signals that
39+
// the CLI is going to exit and the plugin should also exit.
40+
func closeOnCLISocketClose(cancel func()) {
41+
socketAddr, ok := os.LookupEnv(CLIPluginSocketEnvKey)
42+
if !ok {
43+
// if a plugin compiled against a more recent version of docker/cli
44+
// is executed by an older CLI binary, ignore missing environment
45+
// variable and behave as usual
46+
return
47+
}
48+
addr, err := net.ResolveUnixAddr("unix", socketAddr)
49+
if err != nil {
50+
return
51+
}
52+
cliCloseConn, err := net.DialUnix("unix", nil, addr)
53+
if err != nil {
54+
return
55+
}
56+
57+
go func() {
58+
b := make([]byte, 1)
59+
for {
60+
_, err := cliCloseConn.Read(b)
61+
if errors.Is(err, io.EOF) {
62+
cancel()
63+
}
64+
}
65+
}()
66+
}
67+
2768
// RunPlugin executes the specified plugin command
2869
func RunPlugin(dockerCli *command.DockerCli, plugin *cobra.Command, meta manager.Metadata) error {
2970
tcmd := newPluginCommand(dockerCli, plugin, meta)
3071

3172
var persistentPreRunOnce sync.Once
32-
PersistentPreRunE = func(_ *cobra.Command, _ []string) error {
73+
PersistentPreRunE = func(cmd *cobra.Command, _ []string) error {
3374
var err error
3475
persistentPreRunOnce.Do(func() {
76+
cmdContext := cmd.Context()
77+
// TODO: revisit and make sure this check makes sense
78+
// see: https://github.com/docker/cli/pull/4599#discussion_r1422487271
79+
if cmdContext == nil {
80+
cmdContext = context.TODO()
81+
}
82+
ctx, cancel := context.WithCancel(cmdContext)
83+
cmd.SetContext(ctx)
84+
closeOnCLISocketClose(cancel)
85+
3586
var opts []command.InitializeOpt
3687
if os.Getenv("DOCKER_CLI_PLUGIN_USE_DIAL_STDIO") != "" {
3788
opts = append(opts, withPluginClientConn(plugin.Name()))

cmd/docker/docker.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@ package main
22

33
import (
44
"fmt"
5+
"net"
56
"os"
67
"os/exec"
8+
"os/signal"
79
"strings"
810
"syscall"
911

1012
"github.com/docker/cli/cli"
1113
pluginmanager "github.com/docker/cli/cli-plugins/manager"
14+
"github.com/docker/cli/cli-plugins/plugin"
1215
"github.com/docker/cli/cli/command"
1316
"github.com/docker/cli/cli/command/commands"
1417
cliflags "github.com/docker/cli/cli/flags"
1518
"github.com/docker/cli/cli/version"
16-
"github.com/docker/cli/cmd/docker/internal/appcontext"
19+
platformsignals "github.com/docker/cli/cmd/docker/internal/signals"
20+
"github.com/docker/distribution/uuid"
1721
"github.com/docker/docker/api/types/versions"
1822
"github.com/pkg/errors"
1923
"github.com/sirupsen/logrus"
@@ -187,16 +191,59 @@ func setValidateArgs(dockerCli command.Cli, cmd *cobra.Command) {
187191
})
188192
}
189193

194+
func setupPluginSocket() (*net.UnixListener, error) {
195+
return net.ListenUnix("unix", &net.UnixAddr{
196+
Name: "@docker_cli_" + uuid.Generate().String(),
197+
Net: "unix",
198+
})
199+
}
200+
190201
func tryPluginRun(dockerCli command.Cli, cmd *cobra.Command, subcommand string, envs []string) error {
191202
plugincmd, err := pluginmanager.PluginRunCommand(dockerCli, subcommand, cmd)
192203
if err != nil {
193204
return err
194205
}
195206
plugincmd.Env = append(envs, plugincmd.Env...)
196207

208+
var conn *net.UnixConn
209+
listener, err := setupPluginSocket()
210+
if err == nil {
211+
defer listener.Close()
212+
plugincmd.Env = append(plugincmd.Env, plugin.CLIPluginSocketEnvKey+"="+listener.Addr().String())
213+
214+
go func() {
215+
for {
216+
// ignore error here, if we failed to accept a connection,
217+
// conn is nil and we fallback to previous behavior
218+
conn, _ = listener.AcceptUnix()
219+
}
220+
}()
221+
}
222+
223+
const exitLimit = 3
224+
225+
signals := make(chan os.Signal, exitLimit)
226+
signal.Notify(signals, platformsignals.TerminationSignals...)
227+
// signal handling goroutine: listen on signals channel, and if conn is
228+
// non-nil, attempt to close it to let the plugin know to exit. Regardless
229+
// of whether we successfully signal the plugin or not, after 3 SIGINTs,
230+
// we send a SIGKILL to the plugin process and exit
197231
go func() {
198-
// override SIGTERM handler so we let the plugin shut down first
199-
<-appcontext.Context().Done()
232+
retries := 0
233+
for range signals {
234+
if conn != nil {
235+
if err := conn.Close(); err != nil {
236+
_, _ = fmt.Fprintf(dockerCli.Err(), "failed to signal plugin to close: %v\n", err)
237+
}
238+
conn = nil
239+
}
240+
retries++
241+
if retries >= exitLimit {
242+
_, _ = fmt.Fprintf(dockerCli.Err(), "got %d SIGTERM/SIGINTs, forcefully exiting\n", retries)
243+
_ = plugincmd.Process.Kill()
244+
os.Exit(1)
245+
}
246+
}
200247
}()
201248

202249
if err := plugincmd.Run(); err != nil {

cmd/docker/internal/appcontext/appcontext.go

Lines changed: 0 additions & 44 deletions
This file was deleted.

cmd/docker/internal/appcontext/appcontext_unix.go

Lines changed: 0 additions & 12 deletions
This file was deleted.

cmd/docker/internal/appcontext/appcontext_windows.go

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//go:build unix
2+
// +build unix
3+
4+
package signals
5+
6+
import (
7+
"os"
8+
9+
"golang.org/x/sys/unix"
10+
)
11+
12+
// TerminationSignals represents the list of signals we
13+
// want to special-case handle, on this platform.
14+
var TerminationSignals = []os.Signal{unix.SIGTERM, unix.SIGINT}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package signals
2+
3+
import "os"
4+
5+
// TerminationSignals represents the list of signals we
6+
// want to special-case handle, on this platform.
7+
var TerminationSignals = []os.Signal{os.Interrupt}

0 commit comments

Comments
 (0)