Skip to content

Commit 1accbd9

Browse files
[Workspace CLI] better logs streaming for validate (#20238)
* [Workspace CLI] better logs streaming for `validate` * add tests, handle `\b`
1 parent 2aba460 commit 1accbd9

File tree

3 files changed

+173
-38
lines changed

3 files changed

+173
-38
lines changed

components/gitpod-cli/cmd/validate.go

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package cmd
66

77
import (
88
"bufio"
9+
"bytes"
910
"context"
1011
"encoding/json"
1112
"fmt"
@@ -16,6 +17,7 @@ import (
1617
"path/filepath"
1718
"strings"
1819
"time"
20+
"unicode/utf8"
1921

2022
"github.com/gitpod-io/gitpod/common-go/log"
2123
"github.com/gitpod-io/gitpod/gitpod-cli/pkg/supervisor"
@@ -564,59 +566,85 @@ func pipeTask(ctx context.Context, task *api.TaskStatus, supervisor *supervisor.
564566
}
565567
}
566568

567-
func listenTerminal(ctx context.Context, task *api.TaskStatus, supervisor *supervisor.SupervisorClient, runLog *logrus.Entry) error {
568-
listen, err := supervisor.Terminal.Listen(ctx, &api.ListenTerminalRequest{
569-
Alias: task.Terminal,
570-
})
571-
if err != nil {
572-
return err
573-
}
569+
// TerminalReader is an interface for anything that can receive terminal data (this is abstracted for use in testing)
570+
type TerminalReader interface {
571+
Recv() ([]byte, error)
572+
}
574573

575-
pr, pw := io.Pipe()
576-
defer pr.Close()
577-
defer pw.Close()
574+
type LinePrinter func(string)
578575

579-
scanner := bufio.NewScanner(pr)
580-
const maxTokenSize = 1 * 1024 * 1024 // 1 MB
581-
buf := make([]byte, maxTokenSize)
582-
scanner.Buffer(buf, maxTokenSize)
576+
// processTerminalOutput reads from a TerminalReader, processes the output, and calls the provided LinePrinter for each complete line.
577+
// It handles UTF-8 decoding of characters split across chunks and control characters (\n \r \b).
578+
func processTerminalOutput(reader TerminalReader, printLine LinePrinter) error {
579+
var buffer, line bytes.Buffer
583580

584-
go func() {
585-
defer pw.Close()
586-
for {
587-
resp, err := listen.Recv()
588-
if err != nil {
589-
_ = pw.CloseWithError(err)
590-
return
591-
}
581+
flushLine := func() {
582+
if line.Len() > 0 {
583+
printLine(line.String())
584+
line.Reset()
585+
}
586+
}
592587

593-
title := resp.GetTitle()
594-
if title != "" {
595-
task.Presentation.Name = title
588+
for {
589+
data, err := reader.Recv()
590+
if err != nil {
591+
if err == io.EOF {
592+
flushLine()
593+
return nil
596594
}
595+
return err
596+
}
597+
598+
buffer.Write(data)
597599

598-
exitCode := resp.GetExitCode()
599-
if exitCode != 0 {
600-
runLog.Infof("%s: exited with code %d", task.Presentation.Name, exitCode)
600+
for {
601+
r, size := utf8.DecodeRune(buffer.Bytes())
602+
if r == utf8.RuneError && size == 0 {
603+
break // incomplete character at the end
601604
}
602605

603-
data := resp.GetData()
604-
if len(data) > 0 {
605-
_, err := pw.Write(data)
606-
if err != nil {
607-
_ = pw.CloseWithError(err)
608-
return
606+
char := buffer.Next(size)
607+
608+
switch r {
609+
case '\r':
610+
flushLine()
611+
case '\n':
612+
flushLine()
613+
case '\b':
614+
if line.Len() > 0 {
615+
line.Truncate(line.Len() - 1)
609616
}
617+
default:
618+
line.Write(char)
610619
}
611620
}
612-
}()
621+
}
622+
}
613623

614-
for scanner.Scan() {
615-
line := scanner.Text()
624+
func listenTerminal(ctx context.Context, task *api.TaskStatus, supervisor *supervisor.SupervisorClient, runLog *logrus.Entry) error {
625+
listen, err := supervisor.Terminal.Listen(ctx, &api.ListenTerminalRequest{Alias: task.Terminal})
626+
if err != nil {
627+
return err
628+
}
629+
630+
terminalReader := &TerminalReaderAdapter{listen}
631+
printLine := func(line string) {
616632
runLog.Infof("%s: %s", task.Presentation.Name, line)
617633
}
618634

619-
return scanner.Err()
635+
return processTerminalOutput(terminalReader, printLine)
636+
}
637+
638+
type TerminalReaderAdapter struct {
639+
client api.TerminalService_ListenClient
640+
}
641+
642+
func (t *TerminalReaderAdapter) Recv() ([]byte, error) {
643+
resp, err := t.client.Recv()
644+
if err != nil {
645+
return nil, err
646+
}
647+
return resp.GetData(), nil
620648
}
621649

622650
var validateOpts struct {
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2024 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License.AGPL.txt in the project root for license information.
4+
5+
package cmd
6+
7+
import (
8+
"io"
9+
"testing"
10+
11+
"github.com/google/go-cmp/cmp"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
type MockTerminalReader struct {
16+
Data [][]byte
17+
Index int
18+
Errors []error
19+
}
20+
21+
func (m *MockTerminalReader) Recv() ([]byte, error) {
22+
if m.Index >= len(m.Data) {
23+
return nil, io.EOF
24+
}
25+
data := m.Data[m.Index]
26+
err := m.Errors[m.Index]
27+
m.Index++
28+
return data, err
29+
}
30+
31+
func TestProcessTerminalOutput(t *testing.T) {
32+
tests := []struct {
33+
name string
34+
input [][]byte
35+
expected []string
36+
}{
37+
{
38+
name: "Simple line",
39+
input: [][]byte{[]byte("Hello, World!\n")},
40+
expected: []string{"Hello, World!"},
41+
},
42+
{
43+
name: "Windows line ending",
44+
input: [][]byte{[]byte("Hello\r\nWorld\r\n")},
45+
expected: []string{"Hello", "World"},
46+
},
47+
{
48+
name: "Updating line",
49+
input: [][]byte{
50+
[]byte("Hello, World!\r"),
51+
[]byte("Hello, World 2!\r"),
52+
[]byte("Hello, World 3!\n"),
53+
},
54+
expected: []string{"Hello, World!", "Hello, World 2!", "Hello, World 3!"},
55+
},
56+
{
57+
name: "Backspace",
58+
input: [][]byte{[]byte("Helloo\bWorld\n")},
59+
expected: []string{"HelloWorld"},
60+
},
61+
{
62+
name: "Partial UTF-8",
63+
input: [][]byte{[]byte("Hello, 世"), []byte("界\n")},
64+
expected: []string{"Hello, 世界"},
65+
},
66+
{
67+
name: "Partial emoji",
68+
input: [][]byte{
69+
[]byte("Hello "),
70+
{240, 159},
71+
{145, 141},
72+
[]byte("!\n"),
73+
},
74+
expected: []string{"Hello 👍!"},
75+
},
76+
{
77+
name: "Multiple lines in one receive",
78+
input: [][]byte{[]byte("Line1\nLine2\nLine3\n")},
79+
expected: []string{"Line1", "Line2", "Line3"},
80+
},
81+
}
82+
83+
for _, test := range tests {
84+
t.Run(test.name, func(t *testing.T) {
85+
reader := &MockTerminalReader{
86+
Data: test.input,
87+
Errors: make([]error, len(test.input)),
88+
}
89+
90+
var actual []string
91+
printLine := func(line string) {
92+
actual = append(actual, line)
93+
}
94+
95+
err := processTerminalOutput(reader, printLine)
96+
assert.NoError(t, err)
97+
98+
if diff := cmp.Diff(test.expected, actual); diff != "" {
99+
t.Errorf("processTerminalOutput() mismatch (-want +got):\n%s", diff)
100+
}
101+
})
102+
}
103+
}

components/gitpod-cli/go.mod

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ require (
2323
github.com/sirupsen/logrus v1.9.3
2424
github.com/sourcegraph/jsonrpc2 v0.0.0-20200429184054-15c2290dcb37
2525
github.com/spf13/cobra v1.6.1
26+
github.com/stretchr/testify v1.8.4
2627
golang.org/x/sync v0.2.0
2728
golang.org/x/term v0.15.0
2829
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f
@@ -33,6 +34,7 @@ require (
3334
require (
3435
github.com/beorn7/perks v1.0.1 // indirect
3536
github.com/cespare/xxhash/v2 v2.2.0 // indirect
37+
github.com/davecgh/go-spew v1.1.1 // indirect
3638
github.com/gitpod-io/gitpod/components/scrubber v0.0.0-00010101000000-000000000000 // indirect
3739
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
3840
github.com/hashicorp/golang-lru v1.0.2 // indirect
@@ -43,12 +45,14 @@ require (
4345
github.com/mitchellh/reflectwalk v1.0.2 // indirect
4446
github.com/onsi/ginkgo v1.16.5 // indirect
4547
github.com/onsi/gomega v1.24.2 // indirect
48+
github.com/pmezard/go-difflib v1.0.0 // indirect
4649
github.com/prometheus/client_golang v1.16.0 // indirect
4750
github.com/prometheus/client_model v0.3.0 // indirect
4851
github.com/prometheus/common v0.42.0 // indirect
4952
golang.org/x/crypto v0.16.0 // indirect
5053
golang.org/x/sys v0.15.0 // indirect
5154
google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect
55+
gopkg.in/yaml.v3 v3.0.1 // indirect
5256
)
5357

5458
require (

0 commit comments

Comments
 (0)