aboutsummaryrefslogtreecommitdiff
path: root/src/runtime
diff options
context:
space:
mode:
authorKir Kolyshkin <kolyshkin@gmail.com>2026-03-24 22:18:47 -0700
committerKirill Kolyshkin <kolyshkin@gmail.com>2026-03-31 12:38:51 -0700
commit974764364aa09a34cad2d74a6b7c52c12a136ea3 (patch)
tree39a8c03db84a8dc82f3ac56f3bef9cbd220d02b5 /src/runtime
parent2000e27ea6a644ea3623db201d8ba2818e8f5838 (diff)
downloadgo-974764364aa09a34cad2d74a6b7c52c12a136ea3.tar.xz
runtime: unix: sane exit in dieFromSignal for pid 1
A curious bug was reported to kubernetes[1] and runc[2] recently: sometimes runc init reports exit status of 2. Turns out, Go runtime assumes that on any UNIX system signals such as SIGTERM (or any other that has _sigKill flag set in sigtable) with no signal handler set up, will result in kernel terminating the program. This is true, except for PID 1 which gets a custom treatment from the kernel. As a result, when a Go program that runs as PID 1 (which is easy to achieve in Linux by using a new PID namespace) receives such a signal, Go runtime calls dieFromSignal which falls through all the way to exit(2), which is very confusing to a user. This issue can be worked around by the program by adding custom handlers for SIGTERM/SIGINT/SIGHUP, but that requires a goroutine to handle those signals, which, in case of runc, unnecessarily raises its NPROC/pid.max requirement (see discussion at [2]). Since practically exit(2) in dieFromSignal can only happen when the process is running as PID 1, replace it with exit(128+sig) to mimic the shell convention when a child is terminated by a signal. Add a test case which demonstrates the issue and validates the fix (albeit only on Linux). [An earlier version of this patch used to do nothing in dieFromSignal for PID 1 case, but such behavior might be a breaking change for a Go program running in a Linux container as PID 1.] Fixes #78442 [1]: https://github.com/kubernetes/kubernetes/issues/135713 [2]: https://github.com/opencontainers/runc/pull/5189 Change-Id: I196e09e4b5ce84ce2c747a0c2d1fc6e9cf3a6131 Reviewed-on: https://go-review.googlesource.com/c/go/+/759040 LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Ian Lance Taylor <iant@golang.org> Reviewed-by: Junyang Shao <shaojunyang@google.com> Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Diffstat (limited to 'src/runtime')
-rw-r--r--src/runtime/signal_linux_test.go95
-rw-r--r--src/runtime/signal_unix.go6
-rw-r--r--src/runtime/testdata/testprog/signal_pid1.go26
3 files changed, 125 insertions, 2 deletions
diff --git a/src/runtime/signal_linux_test.go b/src/runtime/signal_linux_test.go
new file mode 100644
index 0000000000..1db3154a73
--- /dev/null
+++ b/src/runtime/signal_linux_test.go
@@ -0,0 +1,95 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package runtime_test
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "internal/testenv"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+// TestSignalPid1 verifies that a Go program running as PID 1 with no
+// SIGTERM handler provides a sane exit code upon receiving SIGTERM.
+//
+// The test is Linux-specific because it uses CLONE_NEWPID to run as PID 1.
+func TestSignalPid1(t *testing.T) {
+ t.Parallel()
+
+ exe, err := buildTestProg(t, "testprog")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cmd := testenv.Command(t, exe, "SignalPid1")
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWUSER,
+ UidMappings: []syscall.SysProcIDMap{
+ {ContainerID: 0, HostID: os.Getuid(), Size: 1},
+ },
+ GidMappings: []syscall.SysProcIDMap{
+ {ContainerID: 0, HostID: os.Getgid(), Size: 1},
+ },
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Start(); err != nil {
+ t.Skipf("cannot create PID namespace (may require unprivileged user namespaces): %v", err)
+ }
+
+ waited := false
+ defer func() {
+ if !waited {
+ cmd.Process.Kill()
+ cmd.Wait()
+ }
+ }()
+
+ // Wait for child to signal readiness.
+ r := bufio.NewReader(stdout)
+ line, err := r.ReadString('\n')
+ if err != nil {
+ t.Fatalf("reading from child: %v", err)
+ }
+ if strings.TrimRight(line, "\n") != "ready" {
+ t.Fatalf("unexpected output from child: %q", line)
+ }
+ go io.Copy(io.Discard, r) // Drain any further output.
+
+ const (
+ signal = syscall.SIGTERM
+ expExitCode = int(128 + signal)
+ )
+ // Send signal from outside the child PID namespace.
+ if err := cmd.Process.Signal(signal); err != nil {
+ t.Fatalf("sending signal %d (%q): %v", signal, signal, err)
+ }
+
+ err = cmd.Wait()
+ waited = true
+ t.Logf("child: %v", err)
+ if s := stderr.String(); s != "" {
+ t.Fatalf("child stderr: %s", s)
+ }
+ if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
+ if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
+ if ec := status.ExitStatus(); ec == expExitCode {
+ return // PASS.
+ }
+ }
+ }
+
+ t.Errorf("Want child exited with %d, got: %+v", expExitCode, err)
+}
diff --git a/src/runtime/signal_unix.go b/src/runtime/signal_unix.go
index 5269e2aa7f..02aed754a9 100644
--- a/src/runtime/signal_unix.go
+++ b/src/runtime/signal_unix.go
@@ -989,8 +989,10 @@ func dieFromSignal(sig uint32) {
osyield()
osyield()
- // If we are still somehow running, just exit with the wrong status.
- exit(2)
+ // If we are still somehow running, this probably means we're PID 1
+ // immune to signals with default-terminate. Use a shell convention
+ // of exit(128+sig) to mimic the "terminated by signal".
+ exit(128 + int32(sig))
}
// raisebadsignal is called when a signal is received on a non-Go
diff --git a/src/runtime/testdata/testprog/signal_pid1.go b/src/runtime/testdata/testprog/signal_pid1.go
new file mode 100644
index 0000000000..7446056e2d
--- /dev/null
+++ b/src/runtime/testdata/testprog/signal_pid1.go
@@ -0,0 +1,26 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "time"
+)
+
+func init() {
+ register("SignalPid1", SignalPid1)
+}
+
+// SignalPid1 is a helper for TestSignalPid1.
+func SignalPid1() {
+ if os.Getpid() != 1 {
+ fmt.Fprintln(os.Stderr, "I am not PID 1")
+ return
+ }
+ fmt.Println("ready")
+
+ time.Sleep(time.Hour)
+}