Merge branch 'main' into block-uploader-restore-progress

This commit is contained in:
Lyndon-Li
2026-08-11 17:12:06 +08:00
11 changed files with 469 additions and 47 deletions
+1
View File
@@ -0,0 +1 @@
Cancel hook exec stream on timeout and bound hook timeouts
+1
View File
@@ -0,0 +1 @@
Add printer columns for VolumeSnapshotLocation so kubectl shows provider and phase
@@ -0,0 +1 @@
Verify downloaded build tools against architecture-specific SHA-256 checksums before installation.
@@ -16,7 +16,19 @@ spec:
singular: volumesnapshotlocation
scope: Namespaced
versions:
- name: v1
- additionalPrinterColumns:
- description: Provider is the provider of the volume storage
jsonPath: .spec.provider
name: Provider
type: string
- description: Volume Snapshot Location status such as Available/Unavailable
jsonPath: .status.phase
name: Phase
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1
schema:
openAPIV3Schema:
description: VolumeSnapshotLocation is a location where Velero stores volume
@@ -93,3 +105,4 @@ spec:
type: object
served: true
storage: true
subresources: {}
File diff suppressed because one or more lines are too long
+48 -33
View File
@@ -29,8 +29,18 @@ RUN go install sigs.k8s.io/controller-runtime/tools/setup-envtest@v0.0.0-2026030
ENVTEST_ASSETS_DIR=$(setup-envtest use 1.33.0 --bin-dir /usr/local/kubebuilder/bin -p path) && \
cp -r ${ENVTEST_ASSETS_DIR}/* /usr/local/kubebuilder/bin/
RUN wget --quiet https://github.com/kubernetes-sigs/kubebuilder/releases/download/v3.2.0/kubebuilder_linux_$(go env GOARCH) && \
mv kubebuilder_linux_$(go env GOARCH) /usr/local/kubebuilder/bin/kubebuilder && \
RUN set -eux; \
ARCH="$(go env GOARCH)"; \
case "$ARCH" in \
amd64) KUBEBUILDER_SHA256="102bb0f586dcb50951aded67856483a2ee114057c56475b3cda6051a12832a72" ;; \
arm64) KUBEBUILDER_SHA256="0a340ea925c801aa71344becdefce96eda6fa0bc92352b9c7bcb36a4f8c56314" ;; \
ppc64le) KUBEBUILDER_SHA256="74473d094908caad852a77088f64bb64eb4c79497f6695eb5e9e8bc4bacd9409" ;; \
*) echo "Unsupported kubebuilder architecture: $ARCH" >&2; exit 1 ;; \
esac; \
FILE="kubebuilder_linux_$ARCH"; \
wget --quiet "https://github.com/kubernetes-sigs/kubebuilder/releases/download/v3.2.0/$FILE"; \
echo "$KUBEBUILDER_SHA256 $FILE" | sha256sum -c -; \
mv "$FILE" /usr/local/kubebuilder/bin/kubebuilder; \
chmod +x /usr/local/kubebuilder/bin/kubebuilder
# get controller-tools
@@ -52,26 +62,27 @@ RUN apt-get update && apt-get install -y unzip
# cpu = "ppcle_64"
# snippet from: https://github.com/protocolbuffers/protobuf/blob/d445953603e66eb8992a39b4e10fcafec8501f24/protobuf_release.bzl#L18-L24
# cpu names: https://github.com/bazelbuild/platforms/blob/main/cpu/BUILD
RUN ARCH=$(go env GOARCH) && \
if [ "$ARCH" = "s390x" ] ; then \
ARCH="s390_64"; \
elif [ "$ARCH" = "arm64" ] ; then \
ARCH="aarch_64"; \
elif [ "$ARCH" = "ppc64le" ] ; then \
ARCH="ppcle_64"; \
elif [ "$ARCH" = "ppc64" ] ; then \
ARCH="ppcle_64"; \
else \
ARCH=$(uname -m); \
fi && echo "ARCH=$ARCH" && \
wget --quiet https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-$ARCH.zip && \
unzip protoc-25.2-linux-$ARCH.zip; \
rm *.zip && \
mv bin/protoc /usr/bin/protoc && \
mv include/google /usr/include && \
chmod a+x /usr/include/google && \
chmod a+x /usr/include/google/protobuf && \
chmod a+r -R /usr/include/google && \
RUN set -eux; \
GOARCH="$(go env GOARCH)"; \
case "$GOARCH" in \
amd64) ARCH="x86_64"; PROTOC_SHA256="78ab9c3288919bdaa6cfcec6127a04813cf8a0ce406afa625e48e816abee2878" ;; \
386) ARCH="x86_32"; PROTOC_SHA256="cc1c6e31a9b333c3e6d026aac5fdc1f7d70c6cd8851631505188ca9826acee5a" ;; \
arm64) ARCH="aarch_64"; PROTOC_SHA256="07683afc764e4efa3fa969d5f049fbc2bdfc6b4e7786a0b233413ac0d8753f6b" ;; \
ppc64|ppc64le) ARCH="ppcle_64"; PROTOC_SHA256="cea283337101ed08ff6c76a98461b1d871bac21f41dc1dabdfddaa5d99df9339" ;; \
s390x) ARCH="s390_64"; PROTOC_SHA256="8a13ec6518585f7664d58f929417c9e6d0c4aeedf3bcdd854aeafceb5ef0a389" ;; \
*) echo "Unsupported protoc architecture: $GOARCH" >&2; exit 1 ;; \
esac; \
echo "ARCH=$ARCH"; \
FILE="protoc-25.2-linux-$ARCH.zip"; \
wget --quiet "https://github.com/protocolbuffers/protobuf/releases/download/v25.2/$FILE"; \
echo "$PROTOC_SHA256 $FILE" | sha256sum -c -; \
unzip "$FILE"; \
rm "$FILE"; \
mv bin/protoc /usr/bin/protoc; \
mv include/google /usr/include; \
chmod a+x /usr/include/google; \
chmod a+x /usr/include/google/protobuf; \
chmod a+r -R /usr/include/google; \
chmod +x /usr/bin/protoc
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@${PROTOC_GEN_GO_VERSION} \
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0
@@ -84,17 +95,21 @@ RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@${PROTOC_GEN_GO_VERS
# {{- else if eq .Arch "386" }}i386
# {{- else }}{{ .Arch }}{{ end }}
# {{- if .Arm }}v{{ .Arm }}{{ end -}}
RUN ARCH=$(go env GOARCH) && \
if [ "$ARCH" = "amd64" ] ; then \
ARCH="x86_64"; \
elif [ "$ARCH" = "386" ] ; then \
ARCH="i386"; \
elif [ "$ARCH" = "ppc64le" ] ; then \
ARCH="ppc64"; \
fi && \
wget --quiet "https://github.com/goreleaser/goreleaser/releases/download/v1.26.2/goreleaser_Linux_$ARCH.tar.gz" && \
tar xvf goreleaser_Linux_$ARCH.tar.gz; \
mv goreleaser /usr/bin/goreleaser && \
RUN set -eux; \
GOARCH="$(go env GOARCH)"; \
case "$GOARCH" in \
amd64) ARCH="x86_64"; GORELEASER_SHA256="cfbdf12e3ea20e4c3a209d07311f43c2e0baf20d5cce09bcdc232567e0f34307" ;; \
386) ARCH="i386"; GORELEASER_SHA256="21c236575cccd29588182b570b4ffe83ad8fb96cd3b13b2af79feafd8ae37b1b" ;; \
arm64) ARCH="arm64"; GORELEASER_SHA256="2b984e2932b24be0d638c7dab7357a59d86eb79ca7fee1afd31be5ebb1847cbb" ;; \
arm) ARCH="armv7"; GORELEASER_SHA256="6db2899885be19f123b36192a42dcfb3bb2b3e1009fec7277517969e96d8a7c6" ;; \
ppc64|ppc64le) ARCH="ppc64"; GORELEASER_SHA256="76d060ebb8d48e76fde45983f87040fe3ac0ca37c5ace4648a956959b81bfdf0" ;; \
*) echo "Unsupported goreleaser architecture: $GOARCH" >&2; exit 1 ;; \
esac; \
FILE="goreleaser_Linux_$ARCH.tar.gz"; \
wget --quiet "https://github.com/goreleaser/goreleaser/releases/download/v1.26.2/$FILE"; \
echo "$GORELEASER_SHA256 $FILE" | sha256sum -c -; \
tar xvf "$FILE"; \
mv goreleaser /usr/bin/goreleaser; \
chmod +x /usr/bin/goreleaser
# get golangci-lint
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Copyright 2026 the Velero contributors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
DOCKERFILE="${ROOT_DIR}/hack/build-image/Dockerfile"
verify_block() {
local tool=$1
local start=$2
local end=$3
local hash_variable=$4
local install_pattern=$5
shift 5
local expected_arches=("$@")
local block
block=$(awk -v start="${start}" -v end="${end}" '
$0 ~ start { printing = 1 }
printing { print }
printing && $0 ~ end { exit }
' "${DOCKERFILE}")
if [[ -z "${block}" ]]; then
echo "Unable to find ${tool} install block" >&2
return 1
fi
local actual_arches
actual_arches=$(printf '%s\n' "${block}" |
sed -nE "s/^[[:space:]]*([[:alnum:]_|]+)\).*${hash_variable}=\"([[:xdigit:]]+)\".*/\1 \2/p")
local expected_arch
for expected_arch in "${expected_arches[@]}"; do
if ! printf '%s\n' "${actual_arches}" | awk -v arch="${expected_arch}" '
$1 == arch && length($2) == 64 && $2 ~ /^[0-9a-f]+$/ { found = 1 }
END { exit !found }
'; then
echo "${tool} is missing a lowercase 64-hex SHA-256 for ${expected_arch}" >&2
return 1
fi
done
local actual_count
actual_count=$(printf '%s\n' "${actual_arches}" | sed '/^$/d' | wc -l | tr -d ' ')
if [[ "${actual_count}" -ne "${#expected_arches[@]}" ]]; then
echo "${tool} architecture mapping changed; update this verification gate" >&2
printf '%s\n' "${actual_arches}" >&2
return 1
fi
if ! printf '%s\n' "${block}" | grep -Eq '^ \*\).*Unsupported .+ architecture:.+exit 1'; then
echo "${tool} does not fail closed for unknown architectures" >&2
return 1
fi
local download_line checksum_line install_line
download_line=$(printf '%s\n' "${block}" | grep -n 'wget --quiet' | head -1 | cut -d: -f1)
checksum_line=$(printf '%s\n' "${block}" | grep -n "echo \"\$${hash_variable} \$FILE\" | sha256sum -c -" | head -1 | cut -d: -f1)
install_line=$(printf '%s\n' "${block}" | grep -nE "${install_pattern}" | head -1 | cut -d: -f1)
if [[ -z "${download_line}" || -z "${checksum_line}" || -z "${install_line}" ||
"${download_line}" -ge "${checksum_line}" || "${checksum_line}" -ge "${install_line}" ]]; then
echo "${tool} must download, verify, then install/extract in that order" >&2
return 1
fi
if ! printf '%s\n' "${block}" | grep -q '^RUN set -eux;'; then
echo "${tool} install block must use strict shell error handling" >&2
return 1
fi
}
verify_block \
kubebuilder \
'^RUN set -eux;.*$' \
'^# get controller-tools$' \
KUBEBUILDER_SHA256 \
'mv "\$FILE"' \
amd64 arm64 ppc64le
verify_block \
protoc \
'^# cpu names:' \
'^RUN go install google.golang.org/protobuf' \
PROTOC_SHA256 \
'unzip "\$FILE"' \
amd64 386 arm64 'ppc64|ppc64le' s390x
verify_block \
goreleaser \
'^# goreleaser name template' \
'^# get golangci-lint$' \
GORELEASER_SHA256 \
'tar xvf "\$FILE"' \
amd64 386 arm64 arm 'ppc64|ppc64le'
echo "Verified pinned build-tool checksums and fail-closed install ordering"
@@ -27,6 +27,9 @@ import (
// +kubebuilder:resource:shortName=vsl
// +kubebuilder:object:generate=true
// +kubebuilder:storageversion
// +kubebuilder:printcolumn:name="Provider",type="string",JSONPath=".spec.provider",description="Provider is the provider of the volume storage"
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="Volume Snapshot Location status such as Available/Unavailable"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// VolumeSnapshotLocation is a location where Velero stores volume snapshots.
type VolumeSnapshotLocation struct {
+33 -12
View File
@@ -36,6 +36,10 @@ import (
const defaultTimeout = 30 * time.Second
// maxHookTimeout bounds a user-supplied hook timeout, which can come from a pod
// annotation, so a single hook cannot hold up a backup for an unbounded time.
const maxHookTimeout = 4 * time.Hour
// PodCommandExecutor is capable of executing a command in a container in a pod.
type PodCommandExecutor interface {
// ExecutePodCommand executes a command in a container in a pod. If the command takes longer than
@@ -112,9 +116,15 @@ func (e *defaultPodCommandExecutor) ExecutePodCommand(log logrus.FieldLogger, it
localHook.OnError = api.HookErrorModeFail
}
if localHook.Timeout.Duration == 0 {
// A non-positive timeout is not a valid bound. Timeouts sourced from pod annotations are
// parsed with time.ParseDuration, which accepts negative values, and a negative duration
// would otherwise leave the hook without any timeout at all.
if localHook.Timeout.Duration <= 0 {
localHook.Timeout.Duration = defaultTimeout
}
if localHook.Timeout.Duration > maxHookTimeout {
localHook.Timeout.Duration = maxHookTimeout
}
hookLog := log.WithFields(
logrus.Fields{
@@ -158,23 +168,26 @@ func (e *defaultPodCommandExecutor) ExecutePodCommand(log logrus.FieldLogger, it
Stderr: &stderr,
}
errCh := make(chan error)
// The timeout drives the context so the exec stream is actually canceled, rather than
// being left running on the API server after this function has returned.
ctx, cancel := context.WithTimeout(context.Background(), localHook.Timeout.Duration)
defer cancel()
// Buffered so the goroutine below can always send its result and exit, even when this
// function has already returned on the timeout path.
errCh := make(chan error, 1)
go func() {
err = executor.StreamWithContext(context.Background(), streamOptions)
errCh <- err
streamErr := executor.StreamWithContext(ctx, streamOptions)
// Inspect the local context as soon as the stream returns. Otherwise a stream error
// completed before the deadline could be misclassified if this goroutine sends its
// result before the caller is scheduled to receive it.
errCh <- normalizeExecHookError(streamErr, ctx.Err(), localHook.Timeout.Duration)
}()
var timeoutCh <-chan time.Time
if localHook.Timeout.Duration > 0 {
timer := time.NewTimer(localHook.Timeout.Duration)
defer timer.Stop()
timeoutCh = timer.C
}
select {
case err = <-errCh:
case <-timeoutCh:
case <-ctx.Done():
return errors.Errorf("timed out after %v", localHook.Timeout.Duration)
}
@@ -184,6 +197,14 @@ func (e *defaultPodCommandExecutor) ExecutePodCommand(log logrus.FieldLogger, it
return err
}
func normalizeExecHookError(streamErr, contextErr error, timeout time.Duration) error {
if errors.Is(contextErr, context.DeadlineExceeded) {
return errors.Errorf("timed out after %v", timeout)
}
return streamErr
}
func ensureContainerExists(pod *corev1api.Pod, container string) error {
existsAsMainContainer := slices.ContainsFunc(pod.Spec.Containers, func(c corev1api.Container) bool {
return c.Name == container
+75
View File
@@ -177,6 +177,33 @@ func TestExecutePodCommand(t *testing.T) {
hookError: errors.New("hook error"),
expectedError: "hook error",
},
{
name: "stream deadline exceeded before local timeout",
command: []string{"some", "command"},
expectedContainerName: "foo",
expectedErrorMode: v1.HookErrorModeFail,
expectedTimeout: defaultTimeout,
hookError: context.DeadlineExceeded,
expectedError: context.DeadlineExceeded.Error(),
},
{
// Timeouts from pod annotations go through time.ParseDuration, which accepts
// negative values. Without clamping, the hook would run with no timeout at all.
name: "negative timeout falls back to the default",
command: []string{"some", "command"},
expectedContainerName: "foo",
expectedErrorMode: v1.HookErrorModeFail,
timeout: -1 * time.Second,
expectedTimeout: 30 * time.Second,
},
{
name: "timeout above the maximum is capped",
command: []string{"some", "command"},
expectedContainerName: "foo",
expectedErrorMode: v1.HookErrorModeFail,
timeout: 100000 * time.Hour,
expectedTimeout: maxHookTimeout,
},
}
for _, test := range tests {
@@ -246,6 +273,54 @@ func TestExecutePodCommand(t *testing.T) {
}
}
func TestNormalizeExecHookError(t *testing.T) {
hookErr := errors.New("hook error")
tests := []struct {
name string
streamErr error
contextErr error
expectedError string
preserveStreamErr bool
}{
{
name: "local context deadline exceeded",
streamErr: context.DeadlineExceeded,
contextErr: context.DeadlineExceeded,
expectedError: "timed out after 30s",
},
{
name: "stream deadline exceeded before local timeout",
streamErr: context.DeadlineExceeded,
expectedError: context.DeadlineExceeded.Error(),
preserveStreamErr: true,
},
{
name: "ordinary hook error",
streamErr: hookErr,
expectedError: hookErr.Error(),
preserveStreamErr: true,
},
{
name: "no errors",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := normalizeExecHookError(test.streamErr, test.contextErr, defaultTimeout)
if test.expectedError == "" {
require.NoError(t, err)
return
}
require.EqualError(t, err, test.expectedError)
if test.preserveStreamErr && err != test.streamErr {
t.Fatalf("expected stream error to be returned unchanged")
}
})
}
}
func TestEnsureContainerExists(t *testing.T) {
pod := &corev1api.Pod{
Spec: corev1api.PodSpec{
@@ -0,0 +1,181 @@
/*
Copyright 2026 the Velero contributors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package podexec
import (
"context"
"net/url"
"runtime"
"sync"
"testing"
"time"
"github.com/stretchr/testify/mock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
v1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
velerotest "github.com/vmware-tanzu/velero/pkg/test"
)
const timeoutTestPodJSON = `{
"metadata": {"namespace": "ns", "name": "pod-1"},
"spec": {"containers": [{"name": "container-1"}]}
}`
// contextAwareExecutor returns once its context is canceled, like the SPDY executor does.
type contextAwareExecutor struct {
canceled chan struct{}
canceledOnce bool
}
func (e *contextAwareExecutor) Stream(options remotecommand.StreamOptions) error { return nil }
func (e *contextAwareExecutor) StreamWithContext(ctx context.Context, options remotecommand.StreamOptions) error {
<-ctx.Done()
if !e.canceledOnce {
e.canceledOnce = true
close(e.canceled)
}
return ctx.Err()
}
// contextIgnoringExecutor lets the outer timeout path return before the stream does.
// Once released, the stream goroutine can only exit if its result channel is buffered.
type contextIgnoringExecutor struct {
release <-chan struct{}
returned *sync.WaitGroup
}
func (e *contextIgnoringExecutor) Stream(options remotecommand.StreamOptions) error { return nil }
func (e *contextIgnoringExecutor) StreamWithContext(ctx context.Context, options remotecommand.StreamOptions) error {
defer e.returned.Done()
<-e.release
return nil
}
func newTimeoutTestExecutor(t *testing.T, exec remotecommand.Executor) (*defaultPodCommandExecutor, map[string]any) {
t.Helper()
clientConfig := &rest.Config{}
poster := &mockPoster{}
podCommandExecutor := NewPodCommandExecutor(clientConfig, poster).(*defaultPodCommandExecutor)
factory := &mockStreamExecutorFactory{}
podCommandExecutor.streamExecutorFactory = factory
baseURL, _ := url.Parse("https://some.server")
contentConfig := rest.ClientContentConfig{GroupVersion: schema.GroupVersion{Group: "", Version: "v1"}}
poster.On("Post").Return(rest.NewRequestWithClient(baseURL, "/api/v1", contentConfig, nil))
factory.On("NewSPDYExecutor", clientConfig, "POST", mock.Anything).Return(exec, nil)
pod, err := velerotest.GetAsMap(timeoutTestPodJSON)
if err != nil {
t.Fatal(err)
}
return podCommandExecutor, pod
}
func timeoutTestHook(timeout time.Duration) *v1.ExecHook {
return &v1.ExecHook{
Container: "container-1",
Command: []string{"sh", "-c", "sleep 60"},
Timeout: metav1.Duration{Duration: timeout},
}
}
// A hook that times out must have its exec stream canceled, otherwise the command keeps
// running on the API server after ExecutePodCommand has returned.
func TestExecutePodCommandCancelsStreamOnTimeout(t *testing.T) {
exec := &contextAwareExecutor{canceled: make(chan struct{})}
podCommandExecutor, pod := newTimeoutTestExecutor(t, exec)
err := podCommandExecutor.ExecutePodCommand(velerotest.NewLogger(), pod, "ns", "pod-1", "hookName", timeoutTestHook(100*time.Millisecond))
if err == nil {
t.Fatal("expected a timeout error")
}
select {
case <-exec.canceled:
case <-time.After(2 * time.Second):
t.Fatal("stream was not canceled after the hook timed out")
}
}
// When the stream returns because the context expired, both select cases are ready and one
// is picked at random, so the reported error must not depend on which one wins.
func TestExecutePodCommandTimeoutErrorIsDeterministic(t *testing.T) {
const (
rounds = 50
expectedError = "timed out after 1ms"
)
messages := map[string]int{}
for range rounds {
exec := &contextAwareExecutor{canceled: make(chan struct{})}
podCommandExecutor, pod := newTimeoutTestExecutor(t, exec)
err := podCommandExecutor.ExecutePodCommand(velerotest.NewLogger(), pod, "ns", "pod-1", "hookName", timeoutTestHook(time.Millisecond))
if err == nil {
t.Fatal("expected a timeout error")
}
if err.Error() != expectedError {
t.Fatalf("expected %q, got %q", expectedError, err)
}
messages[err.Error()]++
}
if len(messages) != 1 {
t.Fatalf("expected one error message, got %d: %v", len(messages), messages)
}
}
func TestExecutePodCommandDoesNotLeakOnTimeout(t *testing.T) {
const rounds = 10
runtime.GC()
time.Sleep(200 * time.Millisecond)
before := runtime.NumGoroutine()
release := make(chan struct{})
returned := &sync.WaitGroup{}
for range rounds {
returned.Add(1)
exec := &contextIgnoringExecutor{release: release, returned: returned}
podCommandExecutor, pod := newTimeoutTestExecutor(t, exec)
if err := podCommandExecutor.ExecutePodCommand(velerotest.NewLogger(), pod, "ns", "pod-1", "hookName", timeoutTestHook(50*time.Millisecond)); err == nil {
t.Fatal("expected a timeout error")
}
}
// Every ExecutePodCommand call has already taken the timeout path. Releasing the
// streams now forces their goroutines to send into an errCh with no receiver.
close(release)
returned.Wait()
time.Sleep(200 * time.Millisecond)
runtime.GC()
time.Sleep(200 * time.Millisecond)
if leaked := runtime.NumGoroutine() - before; leaked >= rounds {
t.Fatalf("%d goroutines leaked over %d timed out hooks", leaked, rounds)
}
}