driver/kubernetes: close exec-stream pipes when the stream ends

Signed-off-by: Pierre Gimalac <23154723+pgimalac@users.noreply.github.com>
This commit is contained in:
Pierre Gimalac
2026-07-22 16:15:50 +00:00
parent 4a44f8092f
commit c4b1789093
2 changed files with 128 additions and 1 deletions
+10 -1
View File
@@ -34,6 +34,12 @@ func ExecConn(ctx context.Context, restClient rest.Interface, restConfig *rest.C
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newExecConn(ctx, exec), nil
}
// newExecConn wires a remotecommand.Executor's stdin/stdout streams up as a net.Conn.
// It is split from ExecConn to ease testing.
func newExecConn(ctx context.Context, exec remotecommand.Executor) net.Conn {
stdinR, stdinW := io.Pipe() stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe() stdoutR, stdoutW := io.Pipe()
kc := &kubeConn{ kc := &kubeConn{
@@ -52,8 +58,11 @@ func ExecConn(ctx context.Context, restClient rest.Interface, restConfig *rest.C
if serr != nil && serr != context.Canceled { if serr != nil && serr != context.Canceled {
logrus.Error(serr) logrus.Error(serr)
} }
// Ensure the pipes are closed to unblock Read/Write on kubeConn and avoid infinite hangs.
stdoutW.CloseWithError(serr)
stdinR.CloseWithError(serr)
}() }()
return kc, nil return kc
} }
type kubeConn struct { type kubeConn struct {
+118
View File
@@ -0,0 +1,118 @@
package execconn
import (
"context"
"errors"
"io"
"testing"
"time"
"github.com/stretchr/testify/require"
"k8s.io/client-go/tools/remotecommand"
)
// fakeExecutor is a fake remotecommand.Executor whose StreamWithContext blocks until
// unblock is closed, then returns err. It stands in for the real SPDY exec stream
// to a builder pod, so the pipe-closing behavior in newExecConn can be tested
// without a real Kubernetes API server.
type fakeExecutor struct {
unblock chan struct{}
err error
}
func (f *fakeExecutor) Stream(_ remotecommand.StreamOptions) error {
panic("unimplemented")
}
func (f *fakeExecutor) StreamWithContext(ctx context.Context, _ remotecommand.StreamOptions) error {
select {
case <-f.unblock:
return f.err
case <-ctx.Done():
return context.Cause(ctx)
}
}
func TestNewExecConnPropagatesStreamEnd(t *testing.T) {
t.Run("stream ends with an error", func(t *testing.T) {
streamErr := errors.New("exec stream terminated")
fe := &fakeExecutor{
unblock: make(chan struct{}),
err: streamErr,
}
conn := newExecConn(context.Background(), fe)
close(fe.unblock)
_, err := conn.Read(make([]byte, 16))
require.ErrorIs(t, err, streamErr)
_, err = conn.Write([]byte("test"))
require.ErrorIs(t, err, streamErr)
})
t.Run("stream ends with no error", func(t *testing.T) {
fe := &fakeExecutor{unblock: make(chan struct{})}
conn := newExecConn(context.Background(), fe)
close(fe.unblock) // StreamWithContext returns nil
_, err := conn.Read(make([]byte, 16))
require.ErrorIs(t, err, io.EOF)
_, err = conn.Write([]byte("test"))
require.ErrorIs(t, err, io.ErrClosedPipe)
})
t.Run("stream still active: reads stay blocked, not closed early", func(t *testing.T) {
fe := &fakeExecutor{unblock: make(chan struct{})}
conn := newExecConn(context.Background(), fe)
defer close(fe.unblock)
done := make(chan struct{})
go func() {
buf := make([]byte, 16)
_, _ = conn.Read(buf) //nolint:errcheck
close(done)
}()
select {
case <-done:
t.Fatal("Read returned before the exec stream ended; it should still be blocked")
case <-time.After(200 * time.Millisecond):
// expected: still blocked, exactly like a real in-progress build
}
})
t.Run("stream still active: writes stay blocked, not closed early", func(t *testing.T) {
fe := &fakeExecutor{unblock: make(chan struct{})}
conn := newExecConn(context.Background(), fe)
defer close(fe.unblock)
done := make(chan struct{})
go func() {
_, _ = conn.Write([]byte("test")) //nolint:errcheck
close(done)
}()
select {
case <-done:
t.Fatal("Write returned before the exec stream ended; it should still be blocked")
case <-time.After(200 * time.Millisecond):
// expected: still blocked, exactly like a real in-progress build
}
})
t.Run("stream cancelled by context", func(t *testing.T) {
fe := &fakeExecutor{unblock: make(chan struct{})}
ctx, cancel := context.WithCancelCause(context.Background())
conn := newExecConn(ctx, fe)
defer close(fe.unblock)
cancel(context.Canceled)
_, err := conn.Read(make([]byte, 16))
require.ErrorIs(t, err, context.Canceled)
_, err = conn.Write([]byte("test"))
require.ErrorIs(t, err, context.Canceled)
})
}