Files
OpenShell/sdk/go/openshell/v1/exec_client.go
Varsha 7139df8ca5 fix(exec): preserve output after stdin EOF and verify stream completion (#3359)
* fix(exec): preserve output after stdin EOF and verify stream completion

Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>

* test(exec): cover fair duplex progress and live SDK completion

Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>

* test(sdk): compare large exec buffers with native equality

Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>

* test(exec): use workspace-scoped sandbox name in EOF regression

Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>

---------

Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
2026-09-22 14:11:06 +00:00

340 lines
8.1 KiB
Go

// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"io"
"sync"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)
type execClient struct {
client pb.OpenShellClient
sandboxes SandboxInterface
}
func newExecClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *execClient {
return &execClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes}
}
func (e *execClient) Run(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error) {
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
return nil, err
}
var opt *ExecOptions
if len(opts) > 0 {
opt = &opts[0]
}
req := converter.ExecRequestToProto(sandboxName, command, opt)
req.WorkspaceScope = namedWorkspaceScope(workspace)
stream, err := e.client.ExecSandbox(ctx, req)
if err != nil {
return nil, converter.FromGRPCError(err)
}
var events []*pb.ExecSandboxEvent
for {
ev, recvErr := stream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
return nil, converter.FromGRPCError(recvErr)
}
events = append(events, ev)
}
return converter.ExecResultFromEvents(events)
}
func (e *execClient) Stream(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error) {
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
return nil, err
}
var opt *ExecOptions
if len(opts) > 0 {
opt = &opts[0]
}
req := converter.ExecRequestToProto(sandboxName, command, opt)
req.WorkspaceScope = namedWorkspaceScope(workspace)
streamCtx, cancel := context.WithCancel(ctx)
stream, err := e.client.ExecSandbox(streamCtx, req)
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
return &execStream{stream: stream, cancel: cancel}, nil
}
func (e *execClient) Interactive(ctx context.Context, workspace, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error) {
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
return nil, err
}
var opt *ExecOptions
if len(opts) > 0 {
opt = &opts[0]
}
streamCtx, cancel := context.WithCancel(ctx)
stream, err := e.client.ExecSandboxInteractive(streamCtx)
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
startReq := converter.ExecInteractiveRequestToProto(sandboxName, command, cols, rows, opt)
startReq.WorkspaceScope = namedWorkspaceScope(workspace)
if sendErr := stream.Send(&pb.ExecSandboxInput{
Payload: &pb.ExecSandboxInput_Start{Start: startReq},
}); sendErr != nil {
cancel()
return nil, converter.FromGRPCError(sendErr)
}
return newInteractiveSession(streamCtx, cancel, stream), nil
}
// execStream wraps a server-streaming RPC into the ExecStream interface.
type execStream struct {
stream grpc.ServerStreamingClient[pb.ExecSandboxEvent]
cancel context.CancelFunc
exitCode int
exited bool
hasExit bool
}
func (s *execStream) Next() (*ExecChunk, error) {
if s.exited {
return nil, io.EOF
}
ev, err := s.stream.Recv()
if err == io.EOF {
return nil, io.EOF
}
if err != nil {
return nil, converter.FromGRPCError(err)
}
chunk, code, convErr := converter.ExecChunkFromEvent(ev)
if convErr != nil {
return nil, convErr
}
if chunk != nil {
return chunk, nil
}
// nil chunk with no error means exit event
s.exitCode = code
s.exited = true
s.hasExit = true
return nil, io.EOF
}
func (s *execStream) ExitCode() (int, error) {
if !s.exited {
for {
_, err := s.Next()
if err == io.EOF {
break
}
if err != nil {
return -1, err
}
}
}
if !s.hasExit {
return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"}
}
return s.exitCode, nil
}
func (s *execStream) Close() error {
if s.cancel != nil {
s.cancel()
}
return nil
}
// interactiveSession wraps a bidirectional streaming RPC into the InteractiveSession interface.
// A background goroutine owns the Recv loop and routes events to dataCh (for Read)
// and publishes the final exit/status through done, preventing concurrent Recv calls.
type interactiveSession struct {
stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent]
cancel context.CancelFunc
sendMu sync.Mutex
inputClosed bool
inputCloseErr error
closeOnce sync.Once
closeErr error
dataCh chan []byte
done chan struct{}
errOnce sync.Once
err error
buf []byte
exitCode int
hasExitCode bool
}
func newInteractiveSession(ctx context.Context, cancel context.CancelFunc, stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent]) *interactiveSession {
s := &interactiveSession{
stream: stream,
cancel: cancel,
dataCh: make(chan []byte, 64),
done: make(chan struct{}),
}
go s.readLoop(ctx)
return s
}
func (s *interactiveSession) setErr(err error) {
s.errOnce.Do(func() { s.err = err })
}
func (s *interactiveSession) readLoop(ctx context.Context) {
defer s.cancel()
defer close(s.dataCh)
defer close(s.done)
for {
ev, err := s.stream.Recv()
if err != nil {
if err != io.EOF {
s.setErr(converter.FromGRPCError(err))
} else if ctx.Err() != nil {
s.setErr(converter.FromGRPCError(status.FromContextError(ctx.Err()).Err()))
} else if !s.hasExitCode {
s.setErr(&StatusError{Code: ErrorInternal, Message: "stream ended without exit event"})
}
return
}
if s.hasExitCode {
s.setErr(&StatusError{Code: ErrorInternal, Message: "received event after exit"})
return
}
chunk, code, convErr := converter.ExecChunkFromEvent(ev)
if convErr != nil {
s.setErr(convErr)
return
}
// nil chunk with no error means exit event
if chunk == nil {
s.exitCode = code
s.hasExitCode = true
continue
}
select {
case s.dataCh <- chunk.Data:
case <-ctx.Done():
s.setErr(converter.FromGRPCError(status.FromContextError(ctx.Err()).Err()))
return
}
}
}
func (s *interactiveSession) Read(p []byte) (int, error) {
if len(s.buf) > 0 {
n := copy(p, s.buf)
s.buf = s.buf[n:]
return n, nil
}
data, ok := <-s.dataCh
if !ok {
if s.err != nil {
return 0, s.err
}
return 0, io.EOF
}
n := copy(p, data)
if n < len(data) {
s.buf = append(s.buf, data[n:]...)
}
return n, nil
}
func (s *interactiveSession) Write(p []byte) (int, error) {
s.sendMu.Lock()
defer s.sendMu.Unlock()
if s.inputClosed {
return 0, io.ErrClosedPipe
}
err := s.stream.Send(&pb.ExecSandboxInput{
Payload: &pb.ExecSandboxInput_Stdin{Stdin: p},
})
if err != nil {
return 0, converter.FromGRPCError(err)
}
return len(p), nil
}
func (s *interactiveSession) Resize(cols, rows uint32) error {
s.sendMu.Lock()
defer s.sendMu.Unlock()
if s.inputClosed {
return io.ErrClosedPipe
}
err := s.stream.Send(&pb.ExecSandboxInput{
Payload: &pb.ExecSandboxInput_Resize{
Resize: &pb.ExecSandboxWindowResize{
Cols: cols,
Rows: rows,
},
},
})
if err != nil {
return converter.FromGRPCError(err)
}
return nil
}
func (s *interactiveSession) ExitCode() (int, error) {
<-s.done
if s.hasExitCode {
return s.exitCode, s.err
}
return -1, s.err
}
func (s *interactiveSession) CloseWrite() error {
s.sendMu.Lock()
defer s.sendMu.Unlock()
if !s.inputClosed {
s.inputClosed = true
s.inputCloseErr = s.stream.CloseSend()
}
return s.inputCloseErr
}
func (s *interactiveSession) Cancel() error {
s.closeOnce.Do(func() {
// Cancel before taking sendMu so a blocked Write can release it.
s.cancel()
s.closeErr = s.CloseWrite()
<-s.done
})
return s.closeErr
}
func (s *interactiveSession) Close() error {
return s.Cancel()
}