Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions cli/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,11 +640,14 @@ func Deploy(ctx *Context, opts struct {
if progress.Fraction > 0 && time.Since(lastPrintTime) >= 500*time.Millisecond {
lastPrintTime = time.Now()
fmt.Fprintf(os.Stderr, "\r\033[K") // Clear to end of line
fmt.Fprintf(os.Stderr, "Uploading artifacts: %d%% — %s / ~%s at %s",
line := fmt.Sprintf("Uploading artifacts: %d%% — %s at %s",
int(progress.Fraction*100),
upload.FormatBytes(progress.BytesRead),
upload.FormatBytes(progress.EstimatedTotalBytes),
upload.FormatSpeed(progress.BytesPerSecond))
if progress.ETA > 0 {
line += fmt.Sprintf(" (eta ~%s)", upload.FormatDuration(progress.ETA))
}
fmt.Fprint(os.Stderr, line)
}
})
r = progressReader
Expand Down Expand Up @@ -927,15 +930,27 @@ func Deploy(ctx *Context, opts struct {
return nil
}

// enrichUploadProgress fills in Fraction and EstimatedTotalBytes on a Progress
// snapshot using the atomic uncompressed-byte counter and the known total.
// enrichUploadProgress fills in Fraction and ETA on a Progress snapshot using
// the atomic uncompressed-byte counter and the known total uncompressed size.
//
// Fraction is computed against uncompressed source bytes (not compressed bytes
// sent over the wire). We deliberately avoid projecting a compressed total:
// the source-bytes counter and the network-bytes counter are sampled on
// different sides of a gzip buffer plus io.Pipe, so their ratio swings wildly
// under back-pressure and produces a misleading "estimated total."
//
// ETA is extrapolated in the time domain — elapsed * (1 - frac) / frac — which
// avoids the unit-mixing that broke the old compressed-total math. It's only
// emitted after a brief warmup so the first few ticks (when Fraction is near
// zero) don't produce nonsense.
func enrichUploadProgress(p *upload.Progress, written *atomic.Int64, totalUncompressed int64) {
if totalUncompressed <= 0 {
return
}
p.Fraction = float64(written.Load()) / float64(totalUncompressed)
if p.Fraction > 0 {
p.EstimatedTotalBytes = int64(float64(p.BytesRead) / p.Fraction)
if p.Fraction > 0 && p.Fraction < 1 && p.Duration >= 5*time.Second {
remaining := float64(p.Duration) * (1 - p.Fraction) / p.Fraction
p.ETA = time.Duration(remaining)
}
}

Expand Down
79 changes: 79 additions & 0 deletions cli/commands/deploy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package commands

import (
"sync/atomic"
"testing"
"time"

"miren.dev/runtime/pkg/progress/upload"
)

func TestEnrichUploadProgress(t *testing.T) {
const total int64 = 1_000_000

cases := []struct {
name string
written int64
bytesRead int64
elapsed time.Duration
wantFraction float64
wantETA time.Duration
}{
{
name: "zero total skips entirely",
written: 100,
bytesRead: 50,
elapsed: 5 * time.Second,
wantFraction: 0,
wantETA: 0,
},
{
name: "warmup period suppresses ETA",
written: 10_000,
bytesRead: 5_000,
elapsed: 4 * time.Second,
wantFraction: 0.01,
wantETA: 0,
},
{
name: "steady-state ETA extrapolates from elapsed",
written: 100_000,
bytesRead: 50_000,
elapsed: 10 * time.Second,
wantFraction: 0.1,
wantETA: 90 * time.Second,
},
{
name: "completed upload reports no ETA",
written: total,
bytesRead: 500_000,
elapsed: 60 * time.Second,
wantFraction: 1.0,
wantETA: 0,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var written atomic.Int64
written.Store(tc.written)

p := upload.Progress{
BytesRead: tc.bytesRead,
Duration: tc.elapsed,
}
totalArg := total
if tc.name == "zero total skips entirely" {
totalArg = 0
}
enrichUploadProgress(&p, &written, int64(totalArg))

if p.Fraction != tc.wantFraction {
t.Errorf("Fraction = %v, want %v", p.Fraction, tc.wantFraction)
}
if p.ETA != tc.wantETA {
t.Errorf("ETA = %v, want %v", p.ETA, tc.wantETA)
}
})
}
}
14 changes: 9 additions & 5 deletions cli/commands/deploy_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ type deployInfo struct {
finalUploadSpeed float64

// Upload progress estimation
uploadPct float64 // 0.0–1.0 fraction, -1 if unknown
uploadEstTotal int64 // estimated compressed total bytes
uploadPct float64 // 0.0–1.0 fraction, -1 if unknown
uploadETA time.Duration // estimated time remaining; 0 if unknown

// Source cache info
cachedFiles int32
Expand Down Expand Up @@ -275,7 +275,7 @@ func (m *deployInfo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.uploadBytes = msg.BytesRead
m.finalUploadSpeed = msg.BytesPerSecond
m.uploadPct = msg.Fraction
m.uploadEstTotal = msg.EstimatedTotalBytes
m.uploadETA = msg.ETA

// Continue reading upload progress
if m.uploadProgress != nil {
Expand Down Expand Up @@ -354,10 +354,14 @@ func (m *deployInfo) View() string {
if pct < 0 {
pct = 0
}
pctStr := deployPrefixStyle.Render(fmt.Sprintf("%d%% — %s at %s",
pctText := fmt.Sprintf("%d%% — %s at %s",
int(pct*100),
upload.FormatBytes(m.uploadBytes),
m.uploadSpeed))
m.uploadSpeed)
if m.uploadETA > 0 {
pctText += fmt.Sprintf(" (eta ~%s)", upload.FormatDuration(m.uploadETA))
}
pctStr := deployPrefixStyle.Render(pctText)
currentLine = fmt.Sprintf(" %s Uploading artifacts...\n %s %s",
m.uploadSpin.View(), m.prog.ViewAs(pct), pctStr)
} else if m.currentPhase != "completed" {
Expand Down
10 changes: 5 additions & 5 deletions pkg/progress/upload/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ type ProgressReader struct {

// Progress represents a snapshot of the current upload stats.
type Progress struct {
BytesRead int64
BytesPerSecond float64
Duration time.Duration
Fraction float64 // 0.0–1.0 upload fraction complete
EstimatedTotalBytes int64 // projected total compressed bytes; 0 if unknown
BytesRead int64
BytesPerSecond float64
Duration time.Duration
Fraction float64 // 0.0–1.0 upload fraction complete
ETA time.Duration // estimated time remaining; 0 if unknown
}

// NewProgressReader creates a new progress tracking reader that wraps the given io.Reader.
Expand Down
Loading