# Freeze per-side rate once a transfer goes idle

`3197193`→[main](/content/gh/entireio/git-sync/commits/main/index.html)·
Soph·2mo ago·5 files·+129 added/-18 removed

Previously the rate displayed in the --progress ticker and the end-of-run throughput line was computed as bytes / (now − start), which kept shrinking once bytes stopped flowing — a sync that streamed 11 MB at 367 KB/s would tick down to 366, 365, 364 ... while the post-transfer phases (planning, ref creation) ran. Misleading.

Track per-side lastByteAt on every non-empty Read and surface two new fields on each SideBytes snapshot: ActiveNanos (start → last byte) and IdleNanos (last byte → snapshot time). Renderers compute the rate from ActiveNanos so it freezes at the actual transfer rate, and append a "✓" marker once IdleNanos crosses 750ms (or unconditionally on the final render) so it's visually obvious which sides have finished.

## Sessions

8416b4de95ddView transcript

## Changes

5

- internal/syncer
  
  - Mprogress.go+22/-3
  
  - Mprogress_test.go+53
  
  - Mstats.go+43/-8
  
  - Msyncer.go+1/-1

- internalbridge
  
  - Mmodel.go+10/-6

```
85 unmodified lines

```

const (
    sideSeparator    = "  │  "
    flowArrow        = " → "
    doneMark         = " ✓"
    maxHostnameWidth = 30
    idleThreshold    = 750 * time.Millisecond
)

// formatSide renders a single side as host + bytes + rate, with a flow
// arrow positioned to indicate direction: source on the left of its
// counter, target on the right of its counter. Sides with neither label
// fall back to "name: bytes @ rate".
func formatSide(side SideBytes, dur time.Duration) string {
//
    // The displayed rate is computed against the side's active window
    // (start → last byte) when known, so once a transfer ends the number
    // freezes at the actual transfer rate instead of decaying as the wall
    // clock keeps ticking. forceDone (set on the final render) and a
    // per-side idle gap >idleThreshold append a "✓" marker.
}

func formatSide(side SideBytes, fallbackDur time.Duration, forceDone bool) string {
    name := side.Display
    if name == "" {
        name = side.Label
    }
    name = truncateHost(name, maxHostnameWidth)
    rate := formatBytes(side.Bytes) + " @ " + formatRate(side.Bytes, dur)

rateDur := fallbackDur
    if side.ActiveNanos > 0 {
        rateDur = time.Duration(side.ActiveNanos)
    }
    rate := formatBytes(side.Bytes) + " @ " + formatRate(side.Bytes, rateDur)

done := side.Bytes > 0 && (forceDone || time.Duration(side.IdleNanos) >= idleThreshold)
    if done {
        rate += doneMark
    }

switch side.Label {
    case "source":
        return name + flowArrow + rate
    }
}

// Display carries the hostname extracted from the endpoint URL so live
// renderers can show "github.com → … → host" without re-parsing the URL.
// Empty when the endpoint URL was not http(s) or failed to parse.
//
// ActiveNanos and IdleNanos let renderers freeze the per-side rate once
// a transfer finishes: ActiveNanos spans from stats start to the most
// recent byte read, so dividing Bytes by ActiveNanos yields the rate
// during active streaming rather than a value that decays as wall clock
// keeps advancing past the last byte. IdleNanos is the gap between the
// last byte and the snapshot, used to mark a side as "done".
type SideBytes struct {
    Label       string `json:"label"`
    Bytes       int64  `json:"bytes"`
    Display     string `json:"display,omitempty"`
    ActiveNanos int64  `json:"activeNanos,omitempty"`
    IdleNanos   int64  `json:"idleNanos,omitempty"`
}

// Stats holds the collected transfer statistics.
type Stats struct {
    Sides []SideBytes `json:"sides"`
}

// liveSides returns a snapshot of per-side byte totals for live rendering.
// ActiveNanos and IdleNanos are computed against time.Now() at snapshot
// time so callers do not need to know the collector's start instant.
func (s *statsCollector) liveSides() []SideBytes {
    s.sidesMu.RLock()
    defer s.sidesMu.RUnlock()
    startNanos := s.startedAt.UnixNano()
    nowNanos := time.Now().UnixNano()
    out := make([]SideBytes, 0, len(s.sides))
    for label, sc := range s.sides {
        bytes := sc.bytes.Load()
        lastByte := sc.lastByteAt.Load()
        var activeNanos, idleNanos int64
        if lastByte > 0 {
            activeNanos = lastByte - startNanos
            if activeNanos < 0 {
                activeNanos = 0
            }
            idleNanos = nowNanos - lastByte
            if idleNanos < 0 {
                idleNanos = 0
            }
        }
        out = append(out, SideBytes{
            Label:   label,
            Bytes:   sc.bytes.Load(),
            Display: sc.display,
            ActiveNanos: activeNanos,
            IdleNanos:   idleNanos,
        })
    }
    return out
}
