Skip to content

Commit c8a9ba0

Browse files
committed
fix(local): replace bufio.Scanner with bufio.Reader and implement Execute
- Replace bufio.Scanner with bufio.Reader in streamStdout to handle long lines - Implement Local.Execute using exec.CommandContext with /bin/sh -c - Capture stdout/stderr separately, return stderr in error on non-zero exit - Add unit tests for Execute method
1 parent 014a293 commit c8a9ba0

File tree

2 files changed

+105
-14
lines changed

2 files changed

+105
-14
lines changed

adk/backend/local/local.go

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,41 @@ func (s *Local) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteR
396396
return sr, nil
397397
}
398398

399+
func (s *Local) Execute(ctx context.Context, input *filesystem.ExecuteRequest) (result *filesystem.ExecuteResponse, err error) {
400+
if input.Command == "" {
401+
return nil, fmt.Errorf("command is required")
402+
}
403+
404+
if err := s.validateCommand(input.Command); err != nil {
405+
return nil, err
406+
}
407+
408+
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", input.Command)
409+
410+
var stdoutBuf, stderrBuf strings.Builder
411+
cmd.Stdout = &stdoutBuf
412+
cmd.Stderr = &stderrBuf
413+
414+
exitCode := 0
415+
if err := cmd.Run(); err != nil {
416+
var exitError *exec.ExitError
417+
if errors.As(err, &exitError) {
418+
exitCode = exitError.ExitCode()
419+
stderrStr := stderrBuf.String()
420+
if stderrStr != "" {
421+
return nil, fmt.Errorf("command exited with non-zero code %d: %s", exitCode, stderrStr)
422+
}
423+
return nil, fmt.Errorf("command exited with non-zero code %d", exitCode)
424+
}
425+
return nil, fmt.Errorf("failed to execute command: %w", err)
426+
}
427+
428+
return &filesystem.ExecuteResponse{
429+
Output: stdoutBuf.String(),
430+
ExitCode: &exitCode,
431+
}, nil
432+
}
433+
399434
// initStreamingCmd creates command with stdout and stderr pipes.
400435
func (s *Local) initStreamingCmd(ctx context.Context, command string) (*exec.Cmd, io.ReadCloser, io.ReadCloser, error) {
401436
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
@@ -512,23 +547,27 @@ func (s *Local) readStderrAsync(stderr io.Reader) (*[]byte, <-chan error) {
512547

513548
// streamStdout streams stdout line by line to the writer.
514549
func (s *Local) streamStdout(ctx context.Context, cmd *exec.Cmd, stdout io.Reader, w *schema.StreamWriter[*filesystem.ExecuteResponse]) (bool, error) {
515-
scanner := bufio.NewScanner(stdout)
550+
reader := bufio.NewReader(stdout)
516551
hasOutput := false
517552

518-
for scanner.Scan() {
519-
hasOutput = true
520-
line := scanner.Text() + "\n"
521-
select {
522-
case <-ctx.Done():
523-
_ = cmd.Process.Kill()
524-
return hasOutput, ctx.Err()
525-
default:
526-
w.Send(&filesystem.ExecuteResponse{Output: line}, nil)
553+
for {
554+
line, err := reader.ReadString('\n')
555+
if line != "" {
556+
hasOutput = true
557+
select {
558+
case <-ctx.Done():
559+
_ = cmd.Process.Kill()
560+
return hasOutput, ctx.Err()
561+
default:
562+
w.Send(&filesystem.ExecuteResponse{Output: line}, nil)
563+
}
564+
}
565+
if err != nil {
566+
if err != io.EOF {
567+
return hasOutput, fmt.Errorf("error reading stdout: %w", err)
568+
}
569+
break
527570
}
528-
}
529-
530-
if err := scanner.Err(); err != nil {
531-
return hasOutput, fmt.Errorf("error reading stdout: %w", err)
532571
}
533572

534573
return hasOutput, nil

adk/backend/local/local_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,3 +777,55 @@ func TestExecuteStreaming(t *testing.T) {
777777
assert.Less(t, elapsed, 2*time.Second, "background command should return immediately without waiting")
778778
})
779779
}
780+
781+
func TestExecute(t *testing.T) {
782+
ctx := context.Background()
783+
s, err := NewBackend(ctx, &Config{})
784+
assert.NoError(t, err)
785+
786+
t.Run("simple echo", func(t *testing.T) {
787+
resp, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "echo hello"})
788+
assert.NoError(t, err)
789+
assert.Equal(t, "hello\n", resp.Output)
790+
assert.NotNil(t, resp.ExitCode)
791+
assert.Equal(t, 0, *resp.ExitCode)
792+
})
793+
794+
t.Run("multi-line output", func(t *testing.T) {
795+
resp, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "echo line1 && echo line2 && echo line3"})
796+
assert.NoError(t, err)
797+
assert.Equal(t, "line1\nline2\nline3\n", resp.Output)
798+
assert.Equal(t, 0, *resp.ExitCode)
799+
})
800+
801+
t.Run("empty command", func(t *testing.T) {
802+
_, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: ""})
803+
assert.Error(t, err)
804+
assert.Contains(t, err.Error(), "command is required")
805+
})
806+
807+
t.Run("non-zero exit code", func(t *testing.T) {
808+
_, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "exit 1"})
809+
assert.Error(t, err)
810+
assert.Contains(t, err.Error(), "non-zero code")
811+
})
812+
813+
t.Run("non-zero exit code with stderr", func(t *testing.T) {
814+
_, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "echo fail >&2 && exit 2"})
815+
assert.Error(t, err)
816+
assert.Contains(t, err.Error(), "non-zero code 2")
817+
assert.Contains(t, err.Error(), "fail")
818+
})
819+
820+
t.Run("command not found", func(t *testing.T) {
821+
_, err := s.Execute(ctx, &filesystem.ExecuteRequest{Command: "nonexistent_command_xyz"})
822+
assert.Error(t, err)
823+
})
824+
825+
t.Run("context cancellation", func(t *testing.T) {
826+
cancelCtx, cancel := context.WithCancel(ctx)
827+
cancel()
828+
_, err := s.Execute(cancelCtx, &filesystem.ExecuteRequest{Command: "sleep 10"})
829+
assert.Error(t, err)
830+
})
831+
}

0 commit comments

Comments
 (0)