Skip to content

Commit 995c806

Browse files
authored
Correctly handle sending signal to child process (#7738)
Calling proc.Kill causes the process to exit immediately, which prevents components from executing some clean up functions. To gracefully terminate the components, on Linux and MacOS SIGTERM is sent. On Windows signals are sent to process groups, child process are created in the same group as the parent, so sending a signal to a child process causes the Elastic-Agent to receive the signal as well. Therefore child process need to be created in their own process group. To do so, the child process needs to be created setting the creation flag CREATE_NEW_PROCESS_GROUP, which has the side effect of disbling the CTRL+C signal. Then to correctly terminate the child process `windows.GenerateConsoleCtrlEvent` is called to send CTRL+BREAK signal that is translated by go Go runtime into a `syscall.SIGINT`, which is already correctly handled by Beats.
1 parent 8c4c195 commit 995c806

File tree

9 files changed

+254
-1
lines changed

9 files changed

+254
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Kind can be one of:
2+
# - breaking-change: a change to previously-documented behavior
3+
# - deprecation: functionality that is being removed in a later release
4+
# - bug-fix: fixes a problem in a previous version
5+
# - enhancement: extends functionality but does not break or fix existing behavior
6+
# - feature: new functionality
7+
# - known-issue: problems that we are aware of in a given version
8+
# - security: impacts on the security of a product or a user’s deployment.
9+
# - upgrade: important information for someone upgrading from a prior version
10+
# - other: does not fit into any of the other categories
11+
kind: bug-fix
12+
13+
# Change summary; a 80ish characters long description of the change.
14+
summary: Correctly handle sending signal to child process
15+
16+
# Long description; in case the summary is not enough to describe the change
17+
# this field accommodate a description without length limits.
18+
# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment.
19+
#description:
20+
21+
# Affected component; usually one of "elastic-agent", "fleet-server", "filebeat", "metricbeat", "auditbeat", "all", etc.
22+
component: elastic-agent
23+
24+
# PR URL; optional; the PR number that added the changeset.
25+
# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added.
26+
# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number.
27+
# Please provide it if you are adding a fragment for a different PR.
28+
pr: https://github.com/elastic/elastic-agent/pull/7738
29+
30+
# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of).
31+
# If not present is automatically filled by the tooling with the issue linked to the PR number.
32+
issue: https://github.com/elastic/elastic-agent/issues/6875

magefile.go

+1
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ func getTestBinariesPath() ([]string, error) {
432432
testBinaryPkgs := []string{
433433
filepath.Join(wd, "pkg", "component", "fake", "component"),
434434
filepath.Join(wd, "internal", "pkg", "agent", "install", "testblocking"),
435+
filepath.Join(wd, "pkg", "core", "process", "testsignal"),
435436
}
436437
return testBinaryPkgs, nil
437438
}

pkg/core/process/cmd.go

+23-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import (
1111
"os"
1212
"os/exec"
1313
"path/filepath"
14+
"syscall"
15+
16+
"golang.org/x/sys/windows"
1417
)
1518

1619
func getCmd(ctx context.Context, path string, env []string, uid, gid int, arg ...string) (*exec.Cmd, error) {
@@ -23,14 +26,33 @@ func getCmd(ctx context.Context, path string, env []string, uid, gid int, arg ..
2326
cmd.Env = append(cmd.Env, os.Environ()...)
2427
cmd.Env = append(cmd.Env, env...)
2528
cmd.Dir = filepath.Dir(path)
29+
cmd.SysProcAttr = &syscall.SysProcAttr{
30+
// Signals are sent to process groups, and child process are part of the
31+
// parent's prcoess group. So to send a signal to a
32+
// child process and not have it also affect ourselves
33+
// (the parent process), the child needs to be created in a new
34+
// process group.
35+
//
36+
// Creating a child with CREATE_NEW_PROCESS_GROUP disables CTLR_C_EVENT
37+
// handling for the child, so the only way to gracefully stop it is with
38+
// a CTRL_BREAK_EVENT signal.
39+
// https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
40+
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP,
41+
}
2642

2743
return cmd, nil
2844
}
2945

46+
// killCmd calls Process.Kill
3047
func killCmd(proc *os.Process) error {
3148
return proc.Kill()
3249
}
3350

51+
// terminateCmd sends the CTRL+BREAK (SIGINT) to the process
3452
func terminateCmd(proc *os.Process) error {
35-
return proc.Kill()
53+
// Because we set CREATE_NEW_PROCESS_GROUP when creating the process,
54+
// it CTLR_C_EVENT is disabled, so the only way to gracefully terminate
55+
// the child process is to send a CTRL_BREAK_EVENT.
56+
// https://learn.microsoft.com/en-us/windows/console/generateconsolectrlevent
57+
return windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(proc.Pid))
3658
}

pkg/core/process/cmd_darwin.go

+2
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ func isInt32(val int) bool {
4545
return val >= 0 && val <= math.MaxInt32
4646
}
4747

48+
// killCmd calls Process.Kill
4849
func killCmd(proc *os.Process) error {
4950
return proc.Kill()
5051
}
5152

53+
// terminateCmd sends SIGTERM to the process
5254
func terminateCmd(proc *os.Process) error {
5355
return proc.Signal(syscall.SIGTERM)
5456
}

pkg/core/process/cmd_linux.go

+2
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,12 @@ func isInt32(val int) bool {
4848
return val >= 0 && val <= math.MaxInt32
4949
}
5050

51+
// killCmd calls Process.Kill
5152
func killCmd(proc *os.Process) error {
5253
return proc.Kill()
5354
}
5455

56+
// terminateCmd sends SIGTERM to the process
5557
func terminateCmd(proc *os.Process) error {
5658
return proc.Signal(syscall.SIGTERM)
5759
}

pkg/core/process/cmd_test.go

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
package process
6+
7+
import (
8+
"bufio"
9+
"context"
10+
"io"
11+
"os"
12+
"path/filepath"
13+
"runtime"
14+
"strings"
15+
"syscall"
16+
"testing"
17+
"time"
18+
)
19+
20+
// TestGetCmdAndTerminateCmd ensures the process started by this package
21+
// can be gracefully terminated.
22+
//
23+
// This requires two things:
24+
// 1. getCmd sets the correct SysProcAttr according to the OS
25+
// 2. terminateCmd sends the correct signal according to the OS
26+
//
27+
// On Linux and MacOS, no attribute needs to be set on SysProcAttr
28+
// and terminateCmd send SIGTERM.
29+
// On Windows the process needs the CREATE_NEW_PROCESS_GROUP flag
30+
// and terminateCmd needs to send the CTRL+BREAK event, which the
31+
// Go runtime translates into a syscall.SIGINT.
32+
//
33+
// If this test is failing, run go test with -v so all the output
34+
// of the child process is sent to stdout
35+
func TestGetCmdAndTerminateCmd(t *testing.T) {
36+
wd, err := os.Getwd()
37+
if err != nil {
38+
t.Fatalf("cannot get working directory: %s", err)
39+
}
40+
testBinary := filepath.Join(wd, "testsignal", "testsignal")
41+
42+
if runtime.GOOS == "windows" {
43+
testBinary += ".exe"
44+
}
45+
46+
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
47+
t.Cleanup(cancel)
48+
49+
cmd, err := getCmd(ctx, testBinary, nil, os.Getuid(), os.Getgid(), t.Name())
50+
if err != nil {
51+
t.Fatalf("'getCmd' failed: %s", err)
52+
}
53+
54+
out, err := cmd.StderrPipe()
55+
if err != nil {
56+
t.Fatalf("cannot get stderr pipe for child process: %s", err)
57+
}
58+
sc := bufio.NewScanner(out)
59+
60+
if err := cmd.Start(); err != nil {
61+
t.Fatalf("cannot start child process: %s", err)
62+
}
63+
64+
for sc.Scan() {
65+
line := sc.Text()
66+
if testing.Verbose() {
67+
t.Log("Child process output:", line)
68+
}
69+
if strings.Contains(line, "Wait for signal") {
70+
break
71+
}
72+
}
73+
74+
if err := terminateCmd(cmd.Process); err != nil {
75+
t.Fatalf("did not expect an error from 'terminateCmd': %s", err)
76+
}
77+
78+
expectedMsg := "Got signal: "
79+
if runtime.GOOS == "windows" {
80+
expectedMsg += syscall.SIGINT.String()
81+
} else {
82+
expectedMsg += syscall.SIGTERM.String()
83+
}
84+
85+
for sc.Scan() {
86+
line := sc.Text()
87+
if testing.Verbose() {
88+
t.Log("Child process output:", line)
89+
}
90+
if strings.Contains(line, expectedMsg) {
91+
break
92+
}
93+
}
94+
95+
// Drain the stdout and wait for the child process to exit.
96+
// Because we called cmd.StderrPipe() we need to drain the
97+
// pipe before calling cmd.Wait.
98+
if testing.Verbose() {
99+
for sc.Scan() {
100+
line := sc.Text()
101+
t.Log("Process Output:", line)
102+
}
103+
return
104+
} else {
105+
_, _ = io.Copy(io.Discard, out)
106+
}
107+
108+
// Wait to ensure the child process exits successfully
109+
if err := cmd.Wait(); err != nil {
110+
t.Fatalf("cmd did not finish successfully: %s", err)
111+
}
112+
113+
if cmd.ProcessState.ExitCode() != 0 {
114+
t.Fatalf("process did not finish successfully, exit code: %d", cmd.ProcessState.ExitCode())
115+
}
116+
}

pkg/core/process/testsignal/main.go

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
package main
6+
7+
import (
8+
"log"
9+
"os"
10+
"os/signal"
11+
"syscall"
12+
)
13+
14+
var name string
15+
16+
func main() {
17+
if len(os.Args) == 1 {
18+
log.Println("Usage: ./testsignal [name]")
19+
os.Exit(1)
20+
}
21+
22+
name = os.Args[1]
23+
log.Printf("[%s] Starting, PID %d", name, os.Getpid())
24+
defer log.Printf("[%s] Done", name)
25+
26+
signals := make(chan os.Signal, 1)
27+
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
28+
29+
log.Printf("[%s] Wait for signal", name)
30+
s := <-signals
31+
log.Printf("[%s] Got signal: %s", name, s)
32+
}

pkg/core/process/testsignal/proc.go

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
//go:build !windows
6+
7+
package main
8+
9+
import (
10+
"os/exec"
11+
"syscall"
12+
)
13+
14+
func stopCmd(cmd *exec.Cmd) error {
15+
return cmd.Process.Signal(syscall.SIGINT)
16+
}
17+
18+
func getSysProcAttr() *syscall.SysProcAttr {
19+
return &syscall.SysProcAttr{}
20+
}
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
// or more contributor license agreements. Licensed under the Elastic License 2.0;
3+
// you may not use this file except in compliance with the Elastic License 2.0.
4+
5+
//go:build windows
6+
7+
package main
8+
9+
import (
10+
"os/exec"
11+
"syscall"
12+
13+
"golang.org/x/sys/windows"
14+
)
15+
16+
func stopCmd(cmd *exec.Cmd) error {
17+
return windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid))
18+
}
19+
20+
func getSysProcAttr() *syscall.SysProcAttr {
21+
return &syscall.SysProcAttr{
22+
// This disables the child from receiveing CTRL_C events
23+
// But isolates other siganls from us, aka the parent
24+
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP,
25+
}
26+
}

0 commit comments

Comments
 (0)