Skip to content

Commit a9004d4

Browse files
authored
refactor(progress): generic progress tracking (#1524)
Signed-off-by: Shiwei Zhang <[email protected]>
1 parent 724f56b commit a9004d4

File tree

19 files changed

+1599
-464
lines changed

19 files changed

+1599
-464
lines changed

cmd/oras/internal/display/status/progress/manager.go

+37-42
Original file line numberDiff line numberDiff line change
@@ -23,46 +23,46 @@ import (
2323

2424
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
2525
"oras.land/oras/cmd/oras/internal/display/status/console"
26+
"oras.land/oras/internal/progress"
2627
)
2728

2829
const (
29-
// BufferSize is the size of the status channel buffer.
30-
BufferSize = 1
30+
// bufferSize is the size of the status channel buffer.
31+
bufferSize = 1
3132
framePerSecond = 5
3233
bufFlushDuration = time.Second / framePerSecond
3334
)
3435

3536
var errManagerStopped = errors.New("progress output manager has already been stopped")
3637

37-
// Manager is progress view master
38-
type Manager interface {
39-
Add() (*Messenger, error)
40-
SendAndStop(desc ocispec.Descriptor, prompt string) error
41-
Close() error
42-
}
43-
4438
type manager struct {
4539
status []*status
4640
statusLock sync.RWMutex
4741
console console.Console
4842
updating sync.WaitGroup
4943
renderDone chan struct{}
5044
renderClosed chan struct{}
45+
prompts map[progress.State]string
5146
}
5247

5348
// NewManager initialized a new progress manager.
54-
func NewManager(tty *os.File) (Manager, error) {
49+
func NewManager(tty *os.File, prompts map[progress.State]string) (progress.Manager, error) {
5550
c, err := console.NewConsole(tty)
5651
if err != nil {
5752
return nil, err
5853
}
54+
return newManager(c, prompts), nil
55+
}
56+
57+
func newManager(c console.Console, prompts map[progress.State]string) progress.Manager {
5958
m := &manager{
6059
console: c,
6160
renderDone: make(chan struct{}),
6261
renderClosed: make(chan struct{}),
62+
prompts: prompts,
6363
}
6464
m.start()
65-
return m, nil
65+
return m
6666
}
6767

6868
func (m *manager) start() {
@@ -87,59 +87,54 @@ func (m *manager) start() {
8787
func (m *manager) render() {
8888
m.statusLock.RLock()
8989
defer m.statusLock.RUnlock()
90-
// todo: update size in another routine
90+
91+
// render with culling: only the latter statuses are rendered.
92+
models := m.status
9193
height, width := m.console.GetHeightWidth()
92-
lineCount := len(m.status) * 2
93-
offset := 0
94-
if lineCount > height {
95-
// skip statuses that cannot be rendered
96-
offset = lineCount - height
94+
if n := len(m.status) - height/2; n > 0 {
95+
models = models[n:]
96+
if height%2 == 1 {
97+
view := m.status[n-1].Render(width)
98+
m.console.OutputTo(uint(len(models)*2+1), view[1])
99+
}
97100
}
98-
99-
for ; offset < lineCount; offset += 2 {
100-
status, progress := m.status[offset/2].String(width)
101-
m.console.OutputTo(uint(lineCount-offset), status)
102-
m.console.OutputTo(uint(lineCount-offset-1), progress)
101+
viewHeight := len(models) * 2
102+
for i, model := range models {
103+
view := model.Render(width)
104+
m.console.OutputTo(uint(viewHeight-i*2), view[0])
105+
m.console.OutputTo(uint(viewHeight-i*2-1), view[1])
103106
}
104107
}
105108

106-
// Add appends a new status with 2-line space for rendering.
107-
func (m *manager) Add() (*Messenger, error) {
109+
// Track appends a new status with 2-line space for rendering.
110+
func (m *manager) Track(desc ocispec.Descriptor) (progress.Tracker, error) {
108111
if m.closed() {
109112
return nil, errManagerStopped
110113
}
111114

112-
s := newStatus()
115+
s := newStatus(desc)
113116
m.statusLock.Lock()
114117
m.status = append(m.status, s)
115118
m.statusLock.Unlock()
116119

117120
defer m.console.NewRow()
118121
defer m.console.NewRow()
119-
return m.statusChan(s), nil
122+
return m.newTracker(s), nil
120123
}
121124

122-
// SendAndStop send message for descriptor and stop timing.
123-
func (m *manager) SendAndStop(desc ocispec.Descriptor, prompt string) error {
124-
messenger, err := m.Add()
125-
if err != nil {
126-
return err
127-
}
128-
messenger.Send(prompt, desc, desc.Size)
129-
messenger.Stop()
130-
return nil
131-
}
132-
133-
func (m *manager) statusChan(s *status) *Messenger {
134-
ch := make(chan *status, BufferSize)
125+
func (m *manager) newTracker(s *status) progress.Tracker {
126+
ch := make(chan statusUpdate, bufferSize)
135127
m.updating.Add(1)
136128
go func() {
137129
defer m.updating.Done()
138-
for newStatus := range ch {
139-
s.update(newStatus)
130+
for update := range ch {
131+
update(s)
140132
}
141133
}()
142-
return &Messenger{ch: ch}
134+
return &messenger{
135+
update: ch,
136+
prompts: m.prompts,
137+
}
143138
}
144139

145140
// Close stops all status and waits for updating and rendering.
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
//go:build freebsd || linux || netbsd || openbsd || solaris
2-
31
/*
42
Copyright The ORAS Authors.
53
Licensed under the Apache License, Version 2.0 (the "License");
@@ -18,40 +16,92 @@ limitations under the License.
1816
package progress
1917

2018
import (
21-
"fmt"
19+
"regexp"
2220
"testing"
2321

22+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
2423
"oras.land/oras/cmd/oras/internal/display/status/console"
25-
"oras.land/oras/internal/testutils"
24+
"oras.land/oras/internal/progress"
2625
)
2726

28-
func Test_manager_render(t *testing.T) {
29-
pty, device, err := testutils.NewPty()
30-
if err != nil {
31-
t.Fatal(err)
27+
type mockConsole struct {
28+
console.Console
29+
30+
view []string
31+
height int
32+
width int
33+
}
34+
35+
func newMockConsole(width, height int) *mockConsole {
36+
return &mockConsole{
37+
height: height,
38+
width: width,
39+
}
40+
}
41+
42+
func (c *mockConsole) GetHeightWidth() (int, int) {
43+
return c.height, c.width
44+
}
45+
46+
func (c *mockConsole) NewRow() {
47+
c.view = append(c.view, "")
48+
}
49+
50+
func (c *mockConsole) OutputTo(upCnt uint, str string) {
51+
c.view[len(c.view)-int(upCnt)] = str
52+
}
53+
54+
func (c *mockConsole) Restore() {}
55+
56+
func (c *mockConsole) Save() {}
57+
58+
func Test_manager(t *testing.T) {
59+
desc := ocispec.Descriptor{
60+
MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
61+
Size: 1234567890,
62+
Digest: "sha256:c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646",
63+
Annotations: map[string]string{
64+
"org.opencontainers.image.title": "hello.bin",
65+
},
3266
}
33-
defer device.Close()
34-
sole, err := console.NewConsole(device)
67+
68+
// simulate a console run
69+
c := newMockConsole(80, 24)
70+
m := newManager(c, map[progress.State]string{
71+
progress.StateExists: "Exists",
72+
})
73+
tracker, err := m.Track(desc)
3574
if err != nil {
36-
t.Fatal(err)
75+
t.Fatalf("manager.Track() error = %v, wantErr nil", err)
76+
}
77+
if err = tracker.Update(progress.Status{
78+
State: progress.StateExists,
79+
Offset: -1,
80+
}); err != nil {
81+
t.Errorf("tracker.Update() error = %v, wantErr nil", err)
82+
}
83+
if err := tracker.Close(); err != nil {
84+
t.Errorf("tracker.Close() error = %v, wantErr nil", err)
85+
}
86+
if err := m.Close(); err != nil {
87+
t.Errorf("manager.Close() error = %v, wantErr nil", err)
3788
}
3889

39-
m := &manager{
40-
console: sole,
90+
// verify the console output
91+
want := []string{
92+
"✓ Exists hello.bin 1.15/1.15 GB 100.00% 0s",
93+
" └─ sha256:c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646 ",
4194
}
42-
height, _ := m.console.GetHeightWidth()
43-
for i := 0; i < height; i++ {
44-
if _, err := m.Add(); err != nil {
45-
t.Fatal(err)
46-
}
95+
if len(c.view) != len(want) {
96+
t.Errorf("console view length = %d, want %d", len(c.view), len(want))
4797
}
48-
m.render()
49-
// validate
50-
var want []string
51-
for i := height; i > 0; i -= 2 {
52-
want = append(want, fmt.Sprintf("%dF%s", i, zeroStatus))
98+
escRegexp := regexp.MustCompile("\x1b\\[[0-9]+m")
99+
equal := func(got, want string) bool {
100+
return escRegexp.ReplaceAllString(got, "") == want
53101
}
54-
if err = testutils.MatchPty(pty, device, want...); err != nil {
55-
t.Fatal(err)
102+
for i, v := range want {
103+
if !equal(c.view[i], v) {
104+
t.Errorf("console view[%d] = %q, want %q", i, c.view[i], v)
105+
}
56106
}
57107
}

cmd/oras/internal/display/status/progress/messenger.go

+29-65
Original file line numberDiff line numberDiff line change
@@ -15,81 +15,45 @@ limitations under the License.
1515

1616
package progress
1717

18-
import (
19-
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
20-
"oras.land/oras/cmd/oras/internal/display/status/progress/humanize"
21-
"time"
22-
)
18+
import "oras.land/oras/internal/progress"
2319

24-
// Messenger is progress message channel.
25-
type Messenger struct {
26-
ch chan *status
27-
closed bool
20+
// messenger is progress message channel.
21+
type messenger struct {
22+
update chan statusUpdate
23+
closed bool
24+
prompts map[progress.State]string
2825
}
2926

30-
// Start initializes the messenger.
31-
func (sm *Messenger) Start() {
32-
if sm.ch == nil {
33-
return
34-
}
35-
sm.ch <- startTiming()
36-
}
37-
38-
// Send a status message for the specified descriptor.
39-
func (sm *Messenger) Send(prompt string, descriptor ocispec.Descriptor, offset int64) {
40-
for {
27+
// Update sends the status to the message channel.
28+
func (m *messenger) Update(status progress.Status) error {
29+
switch status.State {
30+
case progress.StateInitialized:
31+
m.update <- updateStatusStartTime()
32+
case progress.StateTransmitting:
4133
select {
42-
case sm.ch <- newStatusMessage(prompt, descriptor, offset):
43-
return
44-
case <-sm.ch:
45-
// purge the channel until successfully pushed
34+
case m.update <- updateStatusMessage(m.prompts[progress.StateTransmitting], status.Offset):
4635
default:
47-
// ch is nil
48-
return
36+
// drop message if channel is full
4937
}
38+
default:
39+
m.update <- updateStatusMessage(m.prompts[status.State], status.Offset)
5040
}
41+
return nil
5142
}
5243

53-
// Stop the messenger after sending a end message.
54-
func (sm *Messenger) Stop() {
55-
if sm.closed {
56-
return
57-
}
58-
sm.ch <- endTiming()
59-
close(sm.ch)
60-
sm.closed = true
61-
}
62-
63-
// newStatus generates a base empty status.
64-
func newStatus() *status {
65-
return &status{
66-
offset: -1,
67-
total: humanize.ToBytes(0),
68-
speedWindow: newSpeedWindow(framePerSecond),
69-
}
70-
}
71-
72-
// newStatusMessage generates a status for messaging.
73-
func newStatusMessage(prompt string, descriptor ocispec.Descriptor, offset int64) *status {
74-
return &status{
75-
prompt: prompt,
76-
descriptor: descriptor,
77-
offset: offset,
78-
}
79-
}
80-
81-
// startTiming creates start timing message.
82-
func startTiming() *status {
83-
return &status{
84-
offset: -1,
85-
startTime: time.Now(),
86-
}
44+
// Fail sends the error to the message channel.
45+
func (m *messenger) Fail(err error) error {
46+
m.update <- updateStatusError(err)
47+
return nil
8748
}
8849

89-
// endTiming creates end timing message.
90-
func endTiming() *status {
91-
return &status{
92-
offset: -1,
93-
endTime: time.Now(),
50+
// Close marks the progress as completed and closes the message channel.
51+
func (m *messenger) Close() error {
52+
if m.closed {
53+
return nil
9454
}
55+
m.update <- updateStatusEndTime()
56+
close(m.update)
57+
m.closed = true
58+
return nil
9559
}

0 commit comments

Comments
 (0)