diff --git a/client/vnc/server/metrics_conn.go b/client/vnc/server/metrics_conn.go index bc0f36d57..2baec750a 100644 --- a/client/vnc/server/metrics_conn.go +++ b/client/vnc/server/metrics_conn.go @@ -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 diff --git a/client/vnc/server/session_encode.go b/client/vnc/server/session_encode.go index 0a5963c64..f34a58400 100644 --- a/client/vnc/server/session_encode.go +++ b/client/vnc/server/session_encode.go @@ -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