mirror of
https://github.com/opencontainers/runc.git
synced 2026-11-06 13:27:23 +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>
86 lines
3.0 KiB
Go
86 lines
3.0 KiB
Go
package libcontainer
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
"golang.org/x/sys/unix"
|
|
|
|
"github.com/opencontainers/runc/libcontainer/system"
|
|
)
|
|
|
|
// errInitDead is returned when "runc init" died before saying what we were
|
|
// waiting for it to say.
|
|
var errInitDead = errors.New("container init process died unexpectedly")
|
|
|
|
// initPollTimeoutMs is how often waitForInitWrite rechecks that "runc init" is
|
|
// still alive.
|
|
const initPollTimeoutMs = 100
|
|
|
|
// initDead tells whether the "runc init" process with the given pid is dead,
|
|
// meaning it will never write anything to us again.
|
|
//
|
|
// A zombie counts as dead even though its thread group may still be alive.
|
|
// Seccomp's SCMP_ACT_KILL (SECCOMP_RET_KILL_THREAD) only kills the thread that
|
|
// performed the syscall, and "runc init" runs on the thread group leader (see
|
|
// [Init]). Once that thread is killed, the remaining Go runtime threads live
|
|
// on forever, keeping the shared descriptor table -- and thus their ends of
|
|
// the sync socket and the exec fifo -- open, so we never see an EOF. Neither
|
|
// wait(2) nor a pidfd report a zombie leader with live threads either, which
|
|
// leaves /proc/[pid]/stat as the only usable signal.
|
|
func initDead(pid int) bool {
|
|
stat, err := system.Stat(pid)
|
|
// Either the process is gone, or its /proc entry is unreadable;
|
|
// either way, there is nothing left to wait for.
|
|
return err != nil || stat.State == system.Zombie || stat.State == system.Dead
|
|
}
|
|
|
|
// checkInitDead is initDead, replaceable by tests.
|
|
var checkInitDead = initDead
|
|
|
|
// pollIn polls fd for input, retrying on EINTR, and tells whether the fd is
|
|
// ready to be read from.
|
|
func pollIn(fd, timeoutMs int) (bool, error) {
|
|
pfds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
|
|
for {
|
|
n, err := unix.Poll(pfds, timeoutMs)
|
|
if errors.Is(err, unix.EINTR) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("poll init: %w", err)
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
}
|
|
|
|
// waitForInitWrite blocks until the given fd, which "runc init" with the given
|
|
// pid is expected to write to, has something to read (or its writing end was
|
|
// closed). It returns errInitDead if init dies before that happens.
|
|
//
|
|
// Should the pid get recycled while we are polling, init is gone entirely,
|
|
// and so are its ends of the fd, which makes the fd readable (with EOF).
|
|
func waitForInitWrite(fd, pid int) error {
|
|
for {
|
|
// Do not poll indefinitely -- a dead init is not always
|
|
// detectable via the fd alone (see [initDead]).
|
|
//
|
|
// Any revents (including POLLHUP) means the caller can now read
|
|
// without blocking, so let it -- data that init managed to write
|
|
// before dying still has to be processed.
|
|
if ready, err := pollIn(fd, initPollTimeoutMs); ready || err != nil {
|
|
return err
|
|
}
|
|
if !checkInitDead(pid) {
|
|
continue
|
|
}
|
|
// Init might have written something (and then died, or execve'd a
|
|
// short-lived container process) after the above poll has timed
|
|
// out. Whatever it wrote before dying is there now, so check again.
|
|
if ready, err := pollIn(fd, 0); ready || err != nil {
|
|
return err
|
|
}
|
|
return errInitDead
|
|
}
|
|
}
|