Throttle VNC encoder JPEG quality and skip frames under write backpressure

This commit is contained in:
Viktor Liu
2026-05-19 12:31:09 +02:00
parent b41fbad5e1
commit 393c102f45
2 changed files with 103 additions and 6 deletions

View File

@@ -51,12 +51,18 @@ type metricsConn struct {
maxFBUBytes uint64
maxFBURects uint64
tickMu sync.Mutex
tickStart time.Time
tickPrevB uint64
tickPrevW uint64
tickPrevF uint64
tickPrevNS uint64
tickMu sync.Mutex
tickStart time.Time
tickPrevB uint64
tickPrevW uint64
tickPrevF uint64
tickPrevNS uint64
// busyMu guards the sliding window used by BusyFraction.
busyMu sync.Mutex
busyLastTime time.Time
busyLastNanos uint64
busyFraction float64
closeOnce sync.Once
done chan struct{}
@@ -131,6 +137,38 @@ func (m *metricsConn) flushTick(final bool) {
})
}
// BusyFraction reports the fraction of recent wall time that Write spent
// blocked in the underlying socket, as an exponentially smoothed value in
// [0, 1]. Approximates downstream backpressure: persistent values near 1
// mean the socket cannot keep up with the encoder's output. Callers can
// throttle JPEG quality or skip frames in response.
func (m *metricsConn) BusyFraction() float64 {
now := time.Now()
ns := atomic.LoadUint64(&m.writeNanos)
m.busyMu.Lock()
defer m.busyMu.Unlock()
if m.busyLastTime.IsZero() {
m.busyLastTime = now
m.busyLastNanos = ns
return 0
}
period := now.Sub(m.busyLastTime)
if period < 50*time.Millisecond {
return m.busyFraction
}
delta := ns - m.busyLastNanos
sample := float64(delta) / float64(period.Nanoseconds())
if sample > 1 {
sample = 1
}
const alpha = 0.4
m.busyFraction = alpha*sample + (1-alpha)*m.busyFraction
m.busyLastTime = now
m.busyLastNanos = ns
return m.busyFraction
}
// isFBUHeader reports whether the given Write payload is the 4-byte
// FramebufferUpdate header (message type 0, padding 0, rect-count high
// byte). Rect bodies are written separately by sendDirtyAndMoves, so the

View File

@@ -37,6 +37,11 @@ func (s *session) processFBRequest(req fbRequest) error {
return err
}
busy := s.applyBackpressure()
if busy >= backpressureSkipThreshold {
return s.sendEmptyUpdate()
}
img, err := s.captureFrame()
if errors.Is(err, errFrameUnchanged) {
// macOS hashes the raw capture bytes and short-circuits when the
@@ -123,6 +128,60 @@ func (s *session) processIncremental(img *image.RGBA) error {
return nil
}
// backpressureSkipThreshold is the BusyFraction at and above which we drop
// the next encode entirely and respond with an empty FramebufferUpdate.
// Above this level the encoder would only stack more bytes behind a socket
// that is already write-blocked, raising end-to-end latency.
const backpressureSkipThreshold = 0.65
// backpressureRampStart is where adaptive quality begins clipping. Below
// this fraction the honoured client quality is used as-is.
const backpressureRampStart = 0.2
// backpressureMinQuality is the floor JPEG quality picked when the socket
// is fully saturated short of the skip threshold.
const backpressureMinQuality = 25
// applyBackpressure samples the socket BusyFraction (if available) and, if
// Tight is in use, ramps the active JPEG quality from the client-honoured
// value down to backpressureMinQuality as the fraction climbs from
// backpressureRampStart toward backpressureSkipThreshold. Returns the
// observed fraction so the caller can decide whether to skip the frame.
func (s *session) applyBackpressure() float64 {
type busyReporter interface{ BusyFraction() float64 }
bs, ok := s.conn.(busyReporter)
if !ok {
return 0
}
frac := bs.BusyFraction()
s.encMu.RLock()
tight := s.tight
s.encMu.RUnlock()
if tight == nil {
return frac
}
base := jpegQualityForLevel(tight.qualityLevel)
if base == 0 {
base = tightJPEGQuality
}
q := base
if frac > backpressureRampStart {
span := backpressureSkipThreshold - backpressureRampStart
t := (frac - backpressureRampStart) / span
if t > 1 {
t = 1
}
q = base - int(float64(base-backpressureMinQuality)*t)
if q < backpressureMinQuality {
q = backpressureMinQuality
}
}
tight.jpegQualityOverride = q
return frac
}
// captureErrorLog emits one log line on the first failure after success,
// then at most once every captureErrThrottle while the capturer keeps
// failing. The "recovered" transition is logged once when err is nil and