Skip to content

Commit 70f526f

Browse files
committed
Merge pull request #14422 from milantracy:shim
PiperOrigin-RevId: 971739193
2 parents 907c32d + 4b9e98b commit 70f526f

15 files changed

Lines changed: 496 additions & 27 deletions

File tree

pkg/shim/v1/proc/BUILD

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,18 @@ go_library(
4545
go_test(
4646
name = "proc_test",
4747
size = "small",
48-
srcs = ["update_test.go"],
48+
srcs = [
49+
"init_state_test.go",
50+
"update_test.go",
51+
"utils_test.go",
52+
],
4953
library = ":proc",
5054
deps = [
5155
"//pkg/shim/v1/runsccmd",
56+
"@com_github_containerd_console//:go_default_library",
5257
"@com_github_containerd_containerd_v2//pkg/protobuf/types:go_default_library",
5358
"@com_github_containerd_containerd_v2//pkg/stdio:go_default_library",
5459
"@com_github_containerd_errdefs//:go_default_library",
60+
"@com_github_containerd_go_runc//:go_default_library",
5561
],
5662
)

pkg/shim/v1/proc/init_state.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,10 @@ func (s *createdState) Start(ctx context.Context, restoreConf *extension.Restore
9292
// To work around that, we treat non-root container in start/restore
9393
// failure state as stopped.
9494
if !s.p.Sandbox {
95-
s.p.io.Close()
95+
// p.io is nil when the process was created with a terminal.
96+
if s.p.io != nil {
97+
s.p.io.Close()
98+
}
9699
s.p.setExited(internalErrorCode)
97100
s.transition(stopped)
98101
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright 2026 The gVisor Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package proc
16+
17+
import (
18+
"context"
19+
"os/exec"
20+
"sync"
21+
"testing"
22+
23+
"github.com/containerd/console"
24+
"github.com/containerd/containerd/v2/pkg/stdio"
25+
runc "github.com/containerd/go-runc"
26+
"gvisor.dev/gvisor/pkg/shim/v1/runsccmd"
27+
)
28+
29+
// fakePlatform is a no-op stdio.Platform.
30+
type fakePlatform struct{}
31+
32+
func (fakePlatform) CopyConsole(ctx context.Context, con console.Console, id, stdin, stdout, stderr string, wg *sync.WaitGroup) (console.Console, error) {
33+
return con, nil
34+
}
35+
36+
func (fakePlatform) ShutdownConsole(context.Context, console.Console) error { return nil }
37+
38+
func (fakePlatform) Close() error { return nil }
39+
40+
// closerIO records whether it was closed. The embedded interface is nil; only
41+
// the methods below are called.
42+
type closerIO struct {
43+
runc.IO
44+
closed bool
45+
}
46+
47+
func (c *closerIO) Close() error {
48+
c.closed = true
49+
return nil
50+
}
51+
52+
func (c *closerIO) Set(*exec.Cmd) {}
53+
54+
// TestCreatedStateStartFailureWithoutIO verifies that a failed start does not
55+
// panic when p.io is nil, as Init.Create leaves it for a terminal.
56+
func TestCreatedStateStartFailureWithoutIO(t *testing.T) {
57+
p := New("id", &runsccmd.Runsc{Command: "/nonexistent-runsc"}, stdio.Stdio{Terminal: true})
58+
p.Platform = fakePlatform{}
59+
p.Sandbox = false
60+
61+
if err := p.Start(t.Context()); err == nil {
62+
t.Fatal("Start() succeeded, want error")
63+
}
64+
if _, ok := p.initState.(*stoppedState); !ok {
65+
t.Errorf("initState = %T, want *stoppedState", p.initState)
66+
}
67+
if got := p.ExitStatus(); got != internalErrorCode {
68+
t.Errorf("ExitStatus() = %d, want %d", got, internalErrorCode)
69+
}
70+
}
71+
72+
// TestCreatedStateStartFailureClosesIO verifies that the failure path still
73+
// closes the IO when it exists.
74+
func TestCreatedStateStartFailureClosesIO(t *testing.T) {
75+
p := New("id", &runsccmd.Runsc{Command: "/nonexistent-runsc"}, stdio.Stdio{})
76+
p.Platform = fakePlatform{}
77+
p.Sandbox = false
78+
cio := &closerIO{}
79+
p.io = cio
80+
81+
if err := p.Start(t.Context()); err == nil {
82+
t.Fatal("Start() succeeded, want error")
83+
}
84+
if !cio.closed {
85+
t.Error("process IO was not closed on start failure")
86+
}
87+
if _, ok := p.initState.(*stoppedState); !ok {
88+
t.Errorf("initState = %T, want *stoppedState", p.initState)
89+
}
90+
}
91+
92+
// TestCreatedStateStartFailureSandbox verifies that a sandbox is left in the
93+
// created state on start failure, so that it can still be deleted.
94+
func TestCreatedStateStartFailureSandbox(t *testing.T) {
95+
p := New("id", &runsccmd.Runsc{Command: "/nonexistent-runsc"}, stdio.Stdio{Terminal: true})
96+
p.Platform = fakePlatform{}
97+
p.Sandbox = true
98+
99+
if err := p.Start(t.Context()); err == nil {
100+
t.Fatal("Start() succeeded, want error")
101+
}
102+
if _, ok := p.initState.(*createdState); !ok {
103+
t.Errorf("initState = %T, want *createdState", p.initState)
104+
}
105+
}

pkg/shim/v1/proc/utils.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func getLastRuntimeError(r *runsccmd.Runsc) (string, error) {
4444
if err != nil {
4545
return "", err
4646
}
47+
defer f.Close()
4748

4849
var (
4950
errMsg string

pkg/shim/v1/proc/utils_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2026 The gVisor Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package proc
16+
17+
import (
18+
"os"
19+
"path/filepath"
20+
"testing"
21+
22+
"gvisor.dev/gvisor/pkg/shim/v1/runsccmd"
23+
)
24+
25+
func openFDs(t *testing.T) int {
26+
t.Helper()
27+
entries, err := os.ReadDir("/proc/self/fd")
28+
if err != nil {
29+
t.Skipf("cannot read /proc/self/fd: %v", err)
30+
}
31+
return len(entries)
32+
}
33+
34+
// TestGetLastRuntimeErrorNoFDLeak verifies that getLastRuntimeError closes the
35+
// log file it opens. It runs on every failed runsc command, so a leak here
36+
// exhausts the fd table of a long-lived shim.
37+
func TestGetLastRuntimeErrorNoFDLeak(t *testing.T) {
38+
logPath := filepath.Join(t.TempDir(), "log.json")
39+
content := `{"level":"info","msg":"starting","time":"2026-01-01T00:00:00Z"}
40+
{"level":"error","msg":"something failed","time":"2026-01-01T00:00:01Z"}
41+
`
42+
if err := os.WriteFile(logPath, []byte(content), 0600); err != nil {
43+
t.Fatalf("WriteFile: %v", err)
44+
}
45+
r := &runsccmd.Runsc{Log: logPath}
46+
47+
// Prime lazy initialization before sampling the fd count.
48+
if _, err := getLastRuntimeError(r); err != nil {
49+
t.Fatalf("getLastRuntimeError: %v", err)
50+
}
51+
52+
before := openFDs(t)
53+
const iterations = 64
54+
for i := 0; i < iterations; i++ {
55+
msg, err := getLastRuntimeError(r)
56+
if err != nil {
57+
t.Fatalf("getLastRuntimeError: %v", err)
58+
}
59+
if want := "something failed"; msg != want {
60+
t.Fatalf("getLastRuntimeError() = %q, want %q", msg, want)
61+
}
62+
}
63+
after := openFDs(t)
64+
65+
// Slack for unrelated runtime activity; a per-call leak adds ~iterations.
66+
if after-before > iterations/2 {
67+
t.Errorf("open fds grew from %d to %d over %d calls; log file is not being closed", before, after, iterations)
68+
}
69+
}
70+
71+
// TestGetLastRuntimeErrorNoLog verifies the empty-log short circuit.
72+
func TestGetLastRuntimeErrorNoLog(t *testing.T) {
73+
msg, err := getLastRuntimeError(&runsccmd.Runsc{})
74+
if err != nil {
75+
t.Fatalf("getLastRuntimeError: %v", err)
76+
}
77+
if msg != "" {
78+
t.Errorf("getLastRuntimeError() = %q, want empty", msg)
79+
}
80+
}

pkg/shim/v1/runsc/BUILD

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,16 @@ go_library(
7070
go_test(
7171
name = "runsc_test",
7272
srcs = [
73+
"debug_test.go",
74+
"epoll_test.go",
7375
"oom_v2_test.go",
7476
"service_test.go",
7577
"state_test.go",
7678
],
7779
library = ":runsc",
7880
deps = [
7981
"//pkg/shim/v1/utils",
82+
"@com_github_containerd_cgroups_v3//cgroup1:go_default_library",
8083
"@com_github_containerd_cgroups_v3//cgroup2:go_default_library",
8184
"@com_github_containerd_containerd_api//events:go_default_library",
8285
"@com_github_containerd_containerd_api//runtime/task/v2:go_default_library",
@@ -85,5 +88,6 @@ go_test(
8588
"@com_github_containerd_errdefs//:go_default_library",
8689
"@com_github_google_go_cmp//cmp:go_default_library",
8790
"@com_github_opencontainers_runtime_spec//specs-go:go_default_library",
91+
"@org_golang_x_sys//unix:go_default_library",
8892
],
8993
)

pkg/shim/v1/runsc/debug.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,22 @@ func setDebugSigHandler() {
3131
dumpCh := make(chan os.Signal, 1)
3232
signal.Notify(dumpCh, syscall.SIGUSR2)
3333
go func() {
34-
buf := make([]byte, 10240)
3534
for range dumpCh {
36-
for {
37-
n := runtime.Stack(buf, true)
38-
if n >= len(buf) {
39-
buf = make([]byte, 2*len(buf))
40-
continue
41-
}
42-
log.L.Debugf("User requested stack trace:\n%s", buf[:n])
43-
}
35+
log.L.Debugf("User requested stack trace:\n%s", dumpStacks())
4436
}
4537
}()
4638
log.L.Debugf("For full process dump run: kill -%d %d", syscall.SIGUSR2, os.Getpid())
4739
})
4840
}
41+
42+
// dumpStacks returns the stack traces of all goroutines.
43+
func dumpStacks() []byte {
44+
buf := make([]byte, 10240)
45+
for {
46+
n := runtime.Stack(buf, true)
47+
if n < len(buf) {
48+
return buf[:n]
49+
}
50+
buf = make([]byte, 2*len(buf))
51+
}
52+
}

pkg/shim/v1/runsc/debug_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright 2026 The gVisor Authors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package runsc
16+
17+
import (
18+
"strings"
19+
"testing"
20+
"time"
21+
)
22+
23+
// TestDumpStacksTerminates verifies that a stack dump returns rather than
24+
// looping forever, which would spin a core and flood the log on every SIGUSR2.
25+
func TestDumpStacksTerminates(t *testing.T) {
26+
done := make(chan []byte, 1)
27+
go func() {
28+
done <- dumpStacks()
29+
}()
30+
31+
select {
32+
case buf := <-done:
33+
if len(buf) == 0 {
34+
t.Fatal("dumpStacks() returned no data")
35+
}
36+
if got := string(buf); !strings.Contains(got, "goroutine ") {
37+
t.Errorf("dumpStacks() = %q, want a goroutine dump", got)
38+
}
39+
case <-time.After(30 * time.Second):
40+
t.Fatal("dumpStacks() did not return; it is looping")
41+
}
42+
}
43+
44+
// TestDumpStacksRepeatable verifies that consecutive dumps do not share a
45+
// buffer.
46+
func TestDumpStacksRepeatable(t *testing.T) {
47+
first := dumpStacks()
48+
second := dumpStacks()
49+
if len(first) == 0 || len(second) == 0 {
50+
t.Fatalf("dumpStacks() returned empty: %d, %d bytes", len(first), len(second))
51+
}
52+
if &first[0] == &second[0] {
53+
t.Error("consecutive dumpStacks() calls returned aliasing buffers")
54+
}
55+
}

pkg/shim/v1/runsc/epoll.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
cgroups "github.com/containerd/cgroups/v3/cgroup1"
2727
"github.com/containerd/containerd/v2/core/events"
2828
"github.com/containerd/containerd/v2/core/runtime"
29+
"github.com/containerd/log"
2930
"golang.org/x/sys/unix"
3031
)
3132

@@ -129,8 +130,11 @@ func (e *epoller) process(ctx context.Context, fd uintptr) {
129130
if err := e.publisher.Publish(ctx, runtime.TaskOOMEventTopic, &TaskOOM{
130131
ContainerID: i.id,
131132
}); err != nil {
132-
// Should not happen.
133-
panic(fmt.Errorf("publish OOM event: %w", err))
133+
if publishFailureIsFatal() {
134+
// Should not happen when an event sink is configured.
135+
panic(fmt.Errorf("publish OOM event: %w", err))
136+
}
137+
log.L.Warningf("Failed to publish OOM event (no containerd event sink): %v", err)
134138
}
135139
}
136140

0 commit comments

Comments
 (0)