mirror of
https://github.com/opencontainers/runc.git
synced 2026-11-06 05:17:05 +00:00
Seccomp's SCMP_ACT_KILL is SECCOMP_RET_KILL_THREAD: it kills the thread
that performed the syscall, not the whole thread group. As "runc init"
locks its main goroutine to the thread group leader, killing that thread
leaves a zombie leader whose remaining Go runtime threads are alive,
keeping the shared descriptor table -- and thus the child ends of the
sync socket and of the exec fifo -- open. The parent therefore never sees
an EOF, and since the thread group is not dead, neither wait(2) nor a
pidfd report anything either, so the parent waits forever.
Add waitForInitWrite, which polls the descriptor with a timeout and, when
nothing arrives, consults /proc/[pid]/stat -- the only thing that reports
a zombie thread group leader. Use it both for reading the sync socket and
for waiting on the exec fifo; the latter replaces the pidfd fast path,
which can not detect this case at all.
Kill and reap the leftover threads once a dead init is detected while
waiting for the exec fifo. They keep the container's stdio descriptors
open, so whoever inherited those would wait for an EOF just as long, and
they keep its cgroup populated, so destroying the container would fail.
Make nonChildProcess.terminate send SIGKILL rather than fail, so that
killDeadInit can use terminate for any init, be it our child ("runc
run") or not ("runc start"). In the latter case, init is only killed,
as reaping it is up to its parent.
Finally, do not wait(2) for the child in the setnsProcess.start error
path: a half-dead child is never reaped, and the deferred error handler
terminates and reaps it anyway.
Fixes #5087
Assisted-by: Claude Opus 5
Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
131 lines
3.1 KiB
Go
131 lines
3.1 KiB
Go
package libcontainer
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
const zombieLeaderEnv = "RUNC_TEST_ZOMBIE_LEADER"
|
|
|
|
// init turns this process into the helper for TestWaitForInitWriteZombieLeader
|
|
// when asked to: it kills its own thread group leader while the rest of its
|
|
// threads (and thus all of its file descriptors) stay alive, the way seccomp's
|
|
// SCMP_ACT_KILL does to "runc init".
|
|
//
|
|
// This has to happen from init, because that is the only place where the Go
|
|
// runtime guarantees we are running on the main thread (i.e. on the thread
|
|
// group leader).
|
|
func init() {
|
|
if os.Getenv(zombieLeaderEnv) != "1" {
|
|
return
|
|
}
|
|
|
|
// Keep a non-leader thread around forever. It has to block in a syscall
|
|
// rather than on a channel, or the Go runtime declares a deadlock and
|
|
// terminates the whole process.
|
|
started := make(chan struct{})
|
|
go func() {
|
|
runtime.LockOSThread()
|
|
close(started)
|
|
for {
|
|
_ = unix.Pause()
|
|
}
|
|
}()
|
|
<-started
|
|
|
|
if unix.Gettid() != unix.Getpid() {
|
|
panic("zombie leader helper is not running on the thread group leader")
|
|
}
|
|
// Terminate this thread, and only this thread.
|
|
_, _, _ = unix.RawSyscall(unix.SYS_EXIT, 0, 0, 0)
|
|
}
|
|
|
|
func TestWaitForInitWriteZombieLeader(t *testing.T) {
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer r.Close()
|
|
|
|
cmd := exec.Command(os.Args[0], "-test.run=^$")
|
|
cmd.Env = append(os.Environ(), zombieLeaderEnv+"=1")
|
|
cmd.ExtraFiles = []*os.File{w}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Only the helper is supposed to hold the writing end now.
|
|
w.Close()
|
|
defer func() {
|
|
// SIGKILL is group-wide, so it does what the helper's own
|
|
// exit(2) could not.
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
}()
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- waitForInitWrite(int(r.Fd()), cmd.Process.Pid)
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, errInitDead) {
|
|
t.Fatalf("waitForInitWrite: want %v, got %v", errInitDead, err)
|
|
}
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("waitForInitWrite did not notice the dead init")
|
|
}
|
|
}
|
|
|
|
func TestWaitForInitWriteReadable(t *testing.T) {
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer r.Close()
|
|
defer w.Close()
|
|
|
|
if _, err := w.Write([]byte("0")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A readable fd has to win over the liveness check.
|
|
checkInitDead = func(int) bool {
|
|
t.Error("liveness checked while the fd is readable")
|
|
return true
|
|
}
|
|
defer func() { checkInitDead = initDead }()
|
|
|
|
if err := waitForInitWrite(int(r.Fd()), 1); err != nil {
|
|
t.Fatalf("waitForInitWrite: %v", err)
|
|
}
|
|
}
|
|
|
|
// Init may write something and die right after poll(2) has timed out, but
|
|
// before its death is noticed. What it wrote must not be lost.
|
|
func TestWaitForInitWriteThenDie(t *testing.T) {
|
|
r, w, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer r.Close()
|
|
defer w.Close()
|
|
|
|
checkInitDead = func(int) bool {
|
|
if _, err := w.Write([]byte("0")); err != nil {
|
|
t.Error(err)
|
|
}
|
|
return true
|
|
}
|
|
defer func() { checkInitDead = initDead }()
|
|
|
|
if err := waitForInitWrite(int(r.Fd()), 1); err != nil {
|
|
t.Fatalf("waitForInitWrite: %v", err)
|
|
}
|
|
}
|