Skip to content

Commit c52c101

Browse files
fix(cmd/docker): prevent race between force-exit goroutine and plugin wait
When a plugin ignores context cancellation and the user sends 3 SIGINTs, the CLI kills the plugin with SIGKILL. Previously the signal goroutine called os.Exit(1) directly; a race existed where plugincmd.Run() could return first (plugin was SIGKILL'd, so ws.ExitStatus() = -1) and the main goroutine would call os.Exit(-1) = exit code 255 before the goroutine reached os.Exit(1). Fix by moving exit-code ownership to the main goroutine. The signal goroutine closes forceExitCh before calling Kill(), guaranteeing the channel is closed before plugincmd.Run() returns (the plugin can only die after Kill() delivers SIGKILL; Run() only returns after the process is reaped). The main goroutine checks forceExitCh after Run() returns and performs the print + os.Exit(1) itself. Also return from the signal goroutine after the force-kill to prevent further loop iterations from calling close(forceExitCh) a second time (which would panic), in case additional signals arrive while the kill is in flight. Fixes a flaky failure in TestPluginSocketCommunication/detached/ the_main_CLI_exits_after_3_signals where exit code 255 was observed instead of 1 on loaded CI runners (RC Docker on Alpine). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Michael Zampani <michael.zampani@docker.com>
1 parent 7922984 commit c52c101

File tree

1 file changed

+27
-5
lines changed

1 file changed

+27
-5
lines changed

cmd/docker/docker.go

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,12 @@ func tryPluginRun(ctx context.Context, dockerCli command.Cli, cmd *cobra.Command
344344
// notify the plugin via the PluginServer (or signal) as appropriate.
345345
const exitLimit = 2
346346

347+
// forceExitCh is closed by the signal goroutine just before it SIGKILLs
348+
// the plugin. The main goroutine checks this after plugincmd.Run() returns
349+
// and owns the final os.Exit(1) call, keeping exit-code ownership in one
350+
// place and avoiding a race between two concurrent os.Exit calls.
351+
forceExitCh := make(chan struct{})
352+
347353
tryTerminatePlugin := func(force bool) {
348354
// If stdin is a TTY, the kernel will forward
349355
// signals to the subprocess because the shared
@@ -368,12 +374,12 @@ func tryPluginRun(ctx context.Context, dockerCli command.Cli, cmd *cobra.Command
368374

369375
// force the process to terminate if it hasn't already
370376
if force {
377+
// Close forceExitCh before Kill so the channel is guaranteed
378+
// to be closed by the time plugincmd.Run() returns: the plugin
379+
// can only exit after Kill() delivers SIGKILL, and Run() only
380+
// returns after the process is reaped.
381+
close(forceExitCh)
371382
_ = plugincmd.Process.Kill()
372-
_, _ = fmt.Fprint(dockerCli.Err(), "got 3 SIGTERM/SIGINTs, forcefully exiting\n")
373-
374-
// Restore terminal in case it was in raw mode.
375-
restoreTerminal(dockerCli)
376-
os.Exit(1)
377383
}
378384
}
379385

@@ -397,10 +403,26 @@ func tryPluginRun(ctx context.Context, dockerCli command.Cli, cmd *cobra.Command
397403
force = true
398404
}
399405
tryTerminatePlugin(force)
406+
if force {
407+
// Plugin has been killed; return to prevent further
408+
// loop iterations from calling close(forceExitCh) again.
409+
return
410+
}
400411
}
401412
}()
402413

403414
if err := plugincmd.Run(); err != nil {
415+
select {
416+
case <-forceExitCh:
417+
// We force-killed the plugin after 3 signals. Print the message
418+
// and exit here so that exit-code ownership stays in the main
419+
// goroutine and we avoid a race with any concurrent os.Exit call.
420+
_, _ = fmt.Fprint(dockerCli.Err(), "got 3 SIGTERM/SIGINTs, forcefully exiting\n")
421+
restoreTerminal(dockerCli)
422+
os.Exit(1)
423+
default:
424+
}
425+
404426
statusCode := 1
405427
exitErr, ok := err.(*exec.ExitError)
406428
if !ok {

0 commit comments

Comments
 (0)