Close standard input when the exec request stream ends

This commit is contained in:
Nikolay Edigaryev 2026-08-10 22:50:32 +01:00
parent 2a88eddd6f
commit ec32558562
2 changed files with 80 additions and 17 deletions

View File

@ -186,11 +186,17 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
}
go func() {
var stdinClosed bool
for {
request, err := stream.Recv()
if err != nil {
// Allow the client to close its sending side while continuing to receive responses
if errors.Is(err, io.EOF) {
if err := closeStdin(stdin, firstExecRequestCommand.Command.GetTty(), &stdinClosed); err != nil {
reportClientError(err)
}
return
}
@ -209,29 +215,18 @@ func (rpc *RPC) Exec(stream grpc.BidiStreamingServer[ExecRequest, ExecResponse])
continue
}
dataToWrite := typedAction.StandardInput.Data
// Check if the remote client has received EOF on their standard input
if len(typedAction.StandardInput.Data) == 0 {
if firstExecRequestCommand.Command.Tty {
// When using pseudo-terminal, we can't simply close the
// standard input, as the file descriptor is shared for
// standard output and standard error too, so we send
// an EOF character instead
dataToWrite = []byte{eofChar}
} else {
// Close the standard input
if err := stdin.Close(); err != nil {
reportClientError(err)
if err := closeStdin(stdin, firstExecRequestCommand.Command.GetTty(), &stdinClosed); err != nil {
reportClientError(err)
return
}
continue
return
}
continue
}
if _, err := stdin.Write(dataToWrite); err != nil {
if _, err := stdin.Write(typedAction.StandardInput.GetData()); err != nil {
reportClientError(err)
return
@ -384,6 +379,28 @@ func signalProcessGroup(process *os.Process, signal syscall.Signal) error {
return nil
}
func closeStdin(stdin io.WriteCloser, tty bool, closed *bool) error {
if stdin == nil || *closed {
return nil
}
if tty {
// When using pseudo-terminal, we can't simply close the
// standard input, as the file descriptor is shared for
// standard output and standard error too, so we send
// an EOF character instead
if _, err := stdin.Write([]byte{eofChar}); err != nil {
return err
}
} else if err := stdin.Close(); err != nil {
return err
}
*closed = true
return nil
}
func (rpc *RPC) Signal(_ context.Context, request *SignalRequest) (*emptypb.Empty, error) {
process, ok := rpc.execs.Load(request.GetExecId())
if !ok {

View File

@ -97,6 +97,52 @@ func TestExecSendsStartedBeforeOutputAndExit(t *testing.T) {
}
}
func TestExecClosesStandardInputOnRequestStreamEOF(t *testing.T) {
_, stream, result := startExecTest(t, &ExecRequest_Command{
Name: "/bin/cat",
Interactive: true,
})
require.NotNil(t, receiveExecResponse(t, stream).GetStarted())
stream.requests <- &ExecRequest{
Type: &ExecRequest_StandardInput{
StandardInput: &IOChunk{Data: []byte("hello")},
},
}
close(stream.requests)
response := receiveExecResponse(t, stream)
require.Equal(t, []byte("hello"), response.GetStandardOutput().GetData())
response = receiveExecResponse(t, stream)
require.EqualValues(t, 0, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
}
func TestExecClosesStandardInputOnEmptyChunk(t *testing.T) {
_, stream, result := startExecTest(t, &ExecRequest_Command{
Name: "/bin/cat",
Interactive: true,
})
require.NotNil(t, receiveExecResponse(t, stream).GetStarted())
stream.requests <- &ExecRequest{
Type: &ExecRequest_StandardInput{
StandardInput: &IOChunk{Data: []byte("hello")},
},
}
stream.requests <- &ExecRequest{
Type: &ExecRequest_StandardInput{
StandardInput: &IOChunk{},
},
}
response := receiveExecResponse(t, stream)
require.Equal(t, []byte("hello"), response.GetStandardOutput().GetData())
response = receiveExecResponse(t, stream)
require.EqualValues(t, 0, response.GetExit().GetCode())
require.NoError(t, receiveExecResult(t, result))
}
func TestExecReportsStartFailureBeforeStarted(t *testing.T) {
tests := []struct {
name string