diff options
| author | Kir Kolyshkin <kolyshkin@gmail.com> | 2026-03-24 22:18:47 -0700 |
|---|---|---|
| committer | Kirill Kolyshkin <kolyshkin@gmail.com> | 2026-03-31 12:38:51 -0700 |
| commit | 974764364aa09a34cad2d74a6b7c52c12a136ea3 (patch) | |
| tree | 39a8c03db84a8dc82f3ac56f3bef9cbd220d02b5 /src/runtime | |
| parent | 2000e27ea6a644ea3623db201d8ba2818e8f5838 (diff) | |
| download | go-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.go | 95 | ||||
| -rw-r--r-- | src/runtime/signal_unix.go | 6 | ||||
| -rw-r--r-- | src/runtime/testdata/testprog/signal_pid1.go | 26 |
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) +} |
